mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(docker): add Docker Compose configuration for database and caching services
- Introduced a new `docker-compose.yml` file to define services for PostgreSQL and Redis, facilitating local development and testing. - Configured health checks for both services to ensure they are ready before the core application starts. - Set up environment variables for the PostgreSQL service to manage user credentials and database initialization. - Added volume mappings for persistent data storage in both PostgreSQL and Redis. refactor(auth): streamline tenant context resolution and error handling - Simplified the `AuthGuard` to throw a `BizException` with a detailed message when tenant IDs do not match. - Removed unnecessary response header settings in the `TenantContextResolver` and middleware, improving clarity and maintainability. - Updated various controllers and services to utilize the new tenant context handling logic, ensuring consistent behavior across the application. feat(super-admin): implement builder debug functionality - Added `SuperAdminBuilderDebugController` to handle image debugging requests with progress tracking. - Introduced an in-memory storage provider for debugging purposes, allowing for temporary file uploads and processing. - Enhanced the dashboard with a new debug page for super admins to test and validate image processing workflows. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -6,15 +6,7 @@ const CORE_API_BASE =
|
|||||||
process.env.API_BASE_URL ??
|
process.env.API_BASE_URL ??
|
||||||
'http://localhost:3000'
|
'http://localhost:3000'
|
||||||
|
|
||||||
const FORWARDED_HEADER_KEYS = [
|
const FORWARDED_HEADER_KEYS = ['cookie', 'authorization', 'x-forwarded-host', 'x-forwarded-proto', 'host']
|
||||||
'cookie',
|
|
||||||
'authorization',
|
|
||||||
'x-tenant-id',
|
|
||||||
'x-tenant-slug',
|
|
||||||
'x-forwarded-host',
|
|
||||||
'x-forwarded-proto',
|
|
||||||
'host',
|
|
||||||
]
|
|
||||||
|
|
||||||
function buildBackendUrl(photoId: string): string {
|
function buildBackendUrl(photoId: string): string {
|
||||||
const base = CORE_API_BASE.endsWith('/') ? CORE_API_BASE.slice(0, -1) : CORE_API_BASE
|
const base = CORE_API_BASE.endsWith('/') ? CORE_API_BASE.slice(0, -1) : CORE_API_BASE
|
||||||
|
|||||||
5
be/apps/core/afilmory-builder.d.ts
vendored
Normal file
5
be/apps/core/afilmory-builder.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module '@afilmory/builder/storage/interfaces.js' {
|
||||||
|
interface CustomStorageConfig {
|
||||||
|
provider: 'super-admin-debug-storage'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,10 +86,9 @@ export class AuthGuard implements CanActivate {
|
|||||||
const tenantId = await this.resolveTenantIdForSession(authSession, method, path)
|
const tenantId = await this.resolveTenantIdForSession(authSession, method, path)
|
||||||
|
|
||||||
if (tenantId !== tenantContext.tenant.id) {
|
if (tenantId !== tenantContext.tenant.id) {
|
||||||
this.log.warn(
|
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||||
`Denied access: session tenant=${tenantId ?? 'n/a'} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`,
|
message: `Denied access: session tenant=${tenantId ?? 'n/a'} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`,
|
||||||
)
|
})
|
||||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
|
|||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
|
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
|
||||||
throwOnMissing: true,
|
throwOnMissing: true,
|
||||||
setResponseHeaders: true,
|
|
||||||
skipInitializationCheck: false,
|
skipInitializationCheck: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||||
setResponseHeaders: false,
|
|
||||||
skipInitializationCheck: true,
|
skipInitializationCheck: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export class RequestContextMiddleware implements HttpMiddleware {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||||
setResponseHeaders: false,
|
|
||||||
throwOnMissing: false,
|
throwOnMissing: false,
|
||||||
skipInitializationCheck: true,
|
skipInitializationCheck: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BuilderConfig, StorageConfig } from '@afilmory/builder'
|
import type { BuilderConfig, StorageConfig } from '@afilmory/builder'
|
||||||
import { Body, ContextParam, Controller, createLogger, Get, Param, Post } from '@afilmory/framework'
|
import { Body, ContextParam, Controller, createLogger, Get, Param, Post } from '@afilmory/framework'
|
||||||
import { Roles } from 'core/guards/roles.decorator'
|
import { Roles } from 'core/guards/roles.decorator'
|
||||||
|
import { createProgressSseResponse } from 'core/modules/shared/http/sse'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
import { runWithBuilderLogRelay } from './builder-log-relay'
|
import { runWithBuilderLogRelay } from './builder-log-relay'
|
||||||
@@ -23,122 +24,30 @@ export class DataSyncController {
|
|||||||
@Post('run')
|
@Post('run')
|
||||||
async run(@Body() body: RunDataSyncDto, @ContextParam() context: Context): Promise<Response> {
|
async run(@Body() body: RunDataSyncDto, @ContextParam() context: Context): Promise<Response> {
|
||||||
const payload = body as unknown as RunDataSyncInput
|
const payload = body as unknown as RunDataSyncInput
|
||||||
const encoder = new TextEncoder()
|
return createProgressSseResponse<DataSyncProgressEvent>({
|
||||||
let cleanup: (() => void) | undefined
|
context,
|
||||||
|
handler: async ({ sendEvent }) => {
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start: (controller) => {
|
|
||||||
let closed = false
|
|
||||||
const rawRequest = context.req.raw
|
|
||||||
const abortSignal = rawRequest.signal
|
|
||||||
|
|
||||||
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
||||||
let abortHandler: (() => void) | undefined
|
|
||||||
|
|
||||||
const cleanupInternal = () => {
|
|
||||||
if (heartbeat) {
|
|
||||||
clearInterval(heartbeat)
|
|
||||||
heartbeat = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abortHandler) {
|
|
||||||
abortSignal.removeEventListener('abort', abortHandler)
|
|
||||||
abortHandler = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!closed) {
|
|
||||||
closed = true
|
|
||||||
try {
|
|
||||||
controller.close()
|
|
||||||
} catch {
|
|
||||||
/* ignore close errors */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendChunk = (chunk: string) => {
|
|
||||||
if (closed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode(chunk))
|
|
||||||
} catch {
|
|
||||||
cleanupInternal()
|
|
||||||
cleanup = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendEvent = (event: DataSyncProgressEvent) => {
|
|
||||||
sendChunk(`event: progress\ndata: ${JSON.stringify(event)}\n\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
heartbeat = setInterval(() => {
|
|
||||||
sendChunk(`: keep-alive ${new Date().toISOString()}\n\n`)
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
abortHandler = () => {
|
|
||||||
const currentCleanup = cleanup
|
|
||||||
cleanup = undefined
|
|
||||||
currentCleanup?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
abortSignal.addEventListener('abort', abortHandler)
|
|
||||||
|
|
||||||
cleanup = () => {
|
|
||||||
cleanupInternal()
|
|
||||||
cleanup = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
sendChunk(': connected\n\n')
|
|
||||||
|
|
||||||
const progressHandler: DataSyncProgressEmitter = async (event) => {
|
const progressHandler: DataSyncProgressEmitter = async (event) => {
|
||||||
sendEvent(event)
|
sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
try {
|
||||||
try {
|
await runWithBuilderLogRelay(progressHandler, () =>
|
||||||
await runWithBuilderLogRelay(progressHandler, () =>
|
this.dataSyncService.runSync(
|
||||||
this.dataSyncService.runSync(
|
{
|
||||||
{
|
builderConfig: payload.builderConfig as BuilderConfig | undefined,
|
||||||
builderConfig: payload.builderConfig as BuilderConfig | undefined,
|
storageConfig: payload.storageConfig as StorageConfig | undefined,
|
||||||
storageConfig: payload.storageConfig as StorageConfig | undefined,
|
dryRun: payload.dryRun ?? false,
|
||||||
dryRun: payload.dryRun ?? false,
|
},
|
||||||
},
|
progressHandler,
|
||||||
progressHandler,
|
),
|
||||||
),
|
)
|
||||||
)
|
} catch (error) {
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
|
|
||||||
this.logger.error('Failed to run data sync', error)
|
|
||||||
sendEvent({ type: 'error', payload: { message } })
|
|
||||||
} finally {
|
|
||||||
const currentCleanup = cleanup
|
|
||||||
cleanup = undefined
|
|
||||||
currentCleanup?.()
|
|
||||||
}
|
|
||||||
})().catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
sendEvent({ type: 'error', payload: { message } })
|
|
||||||
const currentCleanup = cleanup
|
|
||||||
cleanup = undefined
|
|
||||||
currentCleanup?.()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
const currentCleanup = cleanup
|
|
||||||
cleanup = undefined
|
|
||||||
currentCleanup?.()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return new Response(stream, {
|
this.logger.error('Failed to run data sync', error)
|
||||||
headers: {
|
sendEvent({ type: 'error', payload: { message } })
|
||||||
'Content-Type': 'text/event-stream',
|
}
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ContextParam, Controller, Get, Param } from '@afilmory/framework'
|
import { ContextParam, Controller, Get, Param } from '@afilmory/framework'
|
||||||
import { isTenantSlugReserved } from '@afilmory/utils'
|
import { isTenantSlugReserved } from '@afilmory/utils'
|
||||||
|
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
|
||||||
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
|
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
|
||||||
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
|
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
|
||||||
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||||
@@ -30,7 +31,7 @@ export class StaticWebController {
|
|||||||
@Get(`/explory`)
|
@Get(`/explory`)
|
||||||
@SkipTenantGuard()
|
@SkipTenantGuard()
|
||||||
async getStaticWebIndex(@ContextParam() context: Context) {
|
async getStaticWebIndex(@ContextParam() context: Context) {
|
||||||
if (this.isReservedTenant()) {
|
if (this.isReservedTenant({ root: true })) {
|
||||||
return await this.renderTenantRestrictedPage()
|
return await this.renderTenantRestrictedPage()
|
||||||
}
|
}
|
||||||
if (this.shouldRenderTenantMissingPage()) {
|
if (this.shouldRenderTenantMissingPage()) {
|
||||||
@@ -46,7 +47,7 @@ export class StaticWebController {
|
|||||||
|
|
||||||
@Get(`/photos/:photoId`)
|
@Get(`/photos/:photoId`)
|
||||||
async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
|
async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
|
||||||
if (this.isReservedTenant()) {
|
if (this.isReservedTenant({ root: true })) {
|
||||||
return await this.renderTenantRestrictedPage()
|
return await this.renderTenantRestrictedPage()
|
||||||
}
|
}
|
||||||
if (this.shouldRenderTenantMissingPage()) {
|
if (this.shouldRenderTenantMissingPage()) {
|
||||||
@@ -60,31 +61,33 @@ export class StaticWebController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SkipTenantGuard()
|
@SkipTenantGuard()
|
||||||
|
@AllowPlaceholderTenant()
|
||||||
@Get(`${STATIC_DASHBOARD_BASENAME}`)
|
@Get(`${STATIC_DASHBOARD_BASENAME}`)
|
||||||
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
|
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
|
||||||
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
|
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
|
||||||
const pathname = context.req.path
|
const pathname = context.req.path
|
||||||
const isHtmlRoute = this.isHtmlRoute(pathname)
|
const isHtmlRoute = this.isHtmlRoute(pathname)
|
||||||
const normalizedPath = this.normalizePathname(pathname)
|
|
||||||
const allowTenantlessAccess = isHtmlRoute && this.shouldAllowTenantlessDashboardAccess(pathname)
|
const allowTenantlessAccess = isHtmlRoute && this.shouldAllowTenantlessDashboardAccess(pathname)
|
||||||
const isRestrictedEntry = normalizedPath === TENANT_RESTRICTED_ENTRY_PATH
|
|
||||||
if (isHtmlRoute && this.isReservedTenant() && !isRestrictedEntry) {
|
const isReservedTenant = this.isReservedTenant({ root: false })
|
||||||
return await this.renderTenantRestrictedPage()
|
|
||||||
}
|
if (isHtmlRoute) {
|
||||||
if (isHtmlRoute && !allowTenantlessAccess && this.shouldRenderTenantMissingPage()) {
|
if (isReservedTenant) {
|
||||||
return await this.renderTenantMissingPage()
|
return await this.renderTenantRestrictedPage()
|
||||||
|
}
|
||||||
|
if (!allowTenantlessAccess && this.shouldRenderTenantMissingPage()) {
|
||||||
|
return await this.renderTenantMissingPage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.serve(context, this.staticDashboardService, false)
|
const response = await this.serve(context, this.staticDashboardService, false)
|
||||||
if (isHtmlRoute && this.isReservedTenant() && response.status === 404) {
|
|
||||||
return await this.renderTenantRestrictedPage()
|
|
||||||
}
|
|
||||||
if (isHtmlRoute && !allowTenantlessAccess && response.status === 404) {
|
|
||||||
return await this.renderTenantMissingPage()
|
|
||||||
}
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
@SkipTenantGuard()
|
@SkipTenantGuard()
|
||||||
|
@AllowPlaceholderTenant()
|
||||||
@Get('/*')
|
@Get('/*')
|
||||||
async getAsset(@ContextParam() context: Context) {
|
async getAsset(@ContextParam() context: Context) {
|
||||||
return await this.handleRequest(context, false)
|
return await this.handleRequest(context, false)
|
||||||
@@ -188,16 +191,32 @@ export class StaticWebController {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReservedTenant(): boolean {
|
private isReservedTenant({ root = false }: { root?: boolean } = {}): boolean {
|
||||||
const tenantContext = getTenantContext()
|
const tenantContext = getTenantContext()
|
||||||
const slug = tenantContext?.tenant.slug?.toLowerCase()
|
if (!tenantContext) {
|
||||||
if (!slug) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (slug === ROOT_TENANT_SLUG) {
|
|
||||||
|
const tenantSlug = tenantContext.tenant.slug?.toLowerCase() ?? null
|
||||||
|
if (tenantSlug === ROOT_TENANT_SLUG) {
|
||||||
|
return !!root
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedSlug = tenantContext.requestedSlug?.toLowerCase() ?? null
|
||||||
|
|
||||||
|
if (isPlaceholderTenantContext(tenantContext)) {
|
||||||
|
if (!requestedSlug) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const candidate = requestedSlug ?? tenantSlug
|
||||||
|
return isTenantSlugReserved(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantSlug) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return isTenantSlugReserved(slug)
|
|
||||||
|
return isTenantSlugReserved(tenantSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldRenderTenantMissingPage(): boolean {
|
private shouldRenderTenantMissingPage(): boolean {
|
||||||
|
|||||||
@@ -134,11 +134,6 @@ export class AuthRegistrationService {
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
tenant: TenantRecord,
|
tenant: TenantRecord,
|
||||||
): Promise<RegisterTenantResult> {
|
): Promise<RegisterTenantResult> {
|
||||||
headers.set('x-tenant-id', tenant.id)
|
|
||||||
if (tenant.slug) {
|
|
||||||
headers.set('x-tenant-slug', tenant.slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await this.authProvider.getAuth()
|
const auth = await this.authProvider.getAuth()
|
||||||
const response = await auth.api.signUpEmail({
|
const response = await auth.api.signUpEmail({
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { TextDecoder } from 'node:util'
|
||||||
|
|
||||||
import { authUsers } from '@afilmory/db'
|
import { authUsers } from '@afilmory/db'
|
||||||
import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework'
|
import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework'
|
||||||
import { freshSessionMiddleware } from 'better-auth/api'
|
import { freshSessionMiddleware } from 'better-auth/api'
|
||||||
@@ -14,6 +16,7 @@ import type { Context } from 'hono'
|
|||||||
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
|
||||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||||
import { TenantService } from '../tenant/tenant.service'
|
import { TenantService } from '../tenant/tenant.service'
|
||||||
|
import type { TenantRecord } from '../tenant/tenant.types'
|
||||||
import type { SocialProvidersConfig } from './auth.config'
|
import type { SocialProvidersConfig } from './auth.config'
|
||||||
import { AuthProvider } from './auth.provider'
|
import { AuthProvider } from './auth.provider'
|
||||||
import { AuthRegistrationService } from './auth-registration.service'
|
import { AuthRegistrationService } from './auth-registration.service'
|
||||||
@@ -146,11 +149,7 @@ export class AuthController {
|
|||||||
return {
|
return {
|
||||||
user: authContext.user,
|
user: authContext.user,
|
||||||
session: authContext.session,
|
session: authContext.session,
|
||||||
tenant: {
|
tenant: tenantContext,
|
||||||
id: tenantContext.tenant.id,
|
|
||||||
slug: tenantContext.requestedSlug ?? tenantContext.tenant.slug ?? null,
|
|
||||||
isPlaceholder: isPlaceholderTenantContext(tenantContext),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +166,7 @@ export class AuthController {
|
|||||||
@Roles(RoleBit.ADMIN)
|
@Roles(RoleBit.ADMIN)
|
||||||
async getSocialAccounts(@ContextParam() context: Context) {
|
async getSocialAccounts(@ContextParam() context: Context) {
|
||||||
const auth = await this.auth.getAuth()
|
const auth = await this.auth.getAuth()
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
const accounts = await auth.api.listUserAccounts({ headers })
|
const accounts = await auth.api.listUserAccounts({ headers })
|
||||||
const { socialProviders } = await this.systemSettings.getAuthModuleConfig()
|
const { socialProviders } = await this.systemSettings.getAuthModuleConfig()
|
||||||
const enabledProviders = new Set(Object.keys(socialProviders))
|
const enabledProviders = new Set(Object.keys(socialProviders))
|
||||||
@@ -191,7 +190,7 @@ export class AuthController {
|
|||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前未启用该 OAuth Provider' })
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前未启用该 OAuth Provider' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
const callbackURL = this.normalizeCallbackUrl(body?.callbackURL)
|
const callbackURL = this.normalizeCallbackUrl(body?.callbackURL)
|
||||||
const errorCallbackURL = this.normalizeCallbackUrl(body?.errorCallbackURL)
|
const errorCallbackURL = this.normalizeCallbackUrl(body?.errorCallbackURL)
|
||||||
|
|
||||||
@@ -219,7 +218,7 @@ export class AuthController {
|
|||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
const auth = await this.auth.getAuth()
|
const auth = await this.auth.getAuth()
|
||||||
const result = await auth.api.unlinkAccount({
|
const result = await auth.api.unlinkAccount({
|
||||||
headers,
|
headers,
|
||||||
@@ -271,7 +270,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auth = await this.auth.getAuth()
|
const auth = await this.auth.getAuth()
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
const response = await auth.api.signInEmail({
|
const response = await auth.api.signInEmail({
|
||||||
body: {
|
body: {
|
||||||
email,
|
email,
|
||||||
@@ -291,7 +290,7 @@ export class AuthController {
|
|||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
const tenantContext = getTenantContext()
|
const tenantContext = getTenantContext()
|
||||||
|
|
||||||
const auth = await this.auth.getAuth()
|
const auth = await this.auth.getAuth()
|
||||||
@@ -305,13 +304,6 @@ export class AuthController {
|
|||||||
asResponse: true,
|
asResponse: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (tenantContext) {
|
|
||||||
context.header('x-tenant-id', tenantContext.tenant.id)
|
|
||||||
if (tenantContext.tenant.slug) {
|
|
||||||
context.header('x-tenant-slug', tenantContext.tenant.slug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +326,7 @@ export class AuthController {
|
|||||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前操作不支持使用已登录账号' })
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前操作不支持使用已登录账号' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = this.buildTenantAwareHeaders(context)
|
const { headers } = context.req.raw
|
||||||
|
|
||||||
const result = await this.registration.registerTenant(
|
const result = await this.registration.registerTenant(
|
||||||
{
|
{
|
||||||
@@ -360,8 +352,7 @@ export class AuthController {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (result.success && result.tenant) {
|
if (result.success && result.tenant) {
|
||||||
context.header('x-tenant-id', result.tenant.id)
|
return await this.attachTenantMetadata(result.response, result.tenant)
|
||||||
context.header('x-tenant-slug', result.tenant.slug)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.response
|
return result.response
|
||||||
@@ -385,19 +376,6 @@ export class AuthController {
|
|||||||
return await this.auth.handler(context)
|
return await this.auth.handler(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTenantAwareHeaders(context: Context): Headers {
|
|
||||||
const headers = new Headers(context.req.raw.headers)
|
|
||||||
const tenantContext = getTenantContext()
|
|
||||||
if (tenantContext?.tenant?.id) {
|
|
||||||
headers.set('x-tenant-id', tenantContext.tenant.id)
|
|
||||||
const effectiveSlug = tenantContext.requestedSlug ?? tenantContext.tenant.slug
|
|
||||||
if (effectiveSlug) {
|
|
||||||
headers.set('x-tenant-slug', effectiveSlug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeCallbackUrl(url?: string | null): string | undefined {
|
private normalizeCallbackUrl(url?: string | null): string | undefined {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -446,4 +424,56 @@ export class AuthController {
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async attachTenantMetadata(source: Response, tenant: TenantRecord): Promise<Response> {
|
||||||
|
const headers = new Headers(source.headers)
|
||||||
|
headers.delete('content-length')
|
||||||
|
|
||||||
|
let payload: unknown = null
|
||||||
|
let isJson = false
|
||||||
|
let text: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await source.arrayBuffer()
|
||||||
|
if (buffer.byteLength > 0) {
|
||||||
|
text = new TextDecoder().decode(buffer)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
text = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text && text.length > 0) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
isJson = true
|
||||||
|
} catch {
|
||||||
|
payload = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPayload = {
|
||||||
|
id: tenant.id,
|
||||||
|
slug: tenant.slug,
|
||||||
|
name: tenant.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody =
|
||||||
|
isJson && payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
|
? {
|
||||||
|
...(payload as Record<string, unknown>),
|
||||||
|
tenant: tenantPayload,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tenant: tenantPayload,
|
||||||
|
data: payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('content-type', 'application/json; charset=utf-8')
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody), {
|
||||||
|
status: source.status,
|
||||||
|
statusText: source.statusText,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,9 +329,9 @@ export class AuthProvider implements OnModuleInit {
|
|||||||
: (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext)
|
: (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext)
|
||||||
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
|
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
|
||||||
const protocol = this.determineProtocol(host, endpoint.protocol)
|
const protocol = this.determineProtocol(host, endpoint.protocol)
|
||||||
const slugKey = tenantSlug ?? 'global'
|
|
||||||
const optionSignature = this.computeOptionsSignature(options)
|
const optionSignature = this.computeOptionsSignature(options)
|
||||||
const cacheKey = `${protocol}://${host}::${slugKey}::${optionSignature}`
|
const cacheKey = `${protocol}://${host}::${tenantSlug}::${optionSignature}`
|
||||||
|
|
||||||
if (!this.instances.has(cacheKey)) {
|
if (!this.instances.has(cacheKey)) {
|
||||||
const instancePromise = this.createAuthForEndpoint(tenantSlug, options).then((instance) => {
|
const instancePromise = this.createAuthForEndpoint(tenantSlug, options).then((instance) => {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export class RootAccountProvisioner {
|
|||||||
'',
|
'',
|
||||||
'============================================================',
|
'============================================================',
|
||||||
'Root dashboard access provisioned.',
|
'Root dashboard access provisioned.',
|
||||||
` Dashboard URL: ${urls.shift()}`,
|
` Dashboard URL: ${urls.shift()}/root-login`,
|
||||||
...(urls.length > 0 ? urls.map((url) => ` Alternate URL: ${url}`) : []),
|
...(urls.length > 0 ? urls.map((url) => ` Alternate URL: ${url}`) : []),
|
||||||
` Email: ${email}`,
|
` Email: ${email}`,
|
||||||
` Username: ${username}`,
|
` Username: ${username}`,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BuilderConfig,
|
||||||
|
PhotoManifestItem,
|
||||||
|
StorageConfig,
|
||||||
|
StorageManager,
|
||||||
|
StorageObject,
|
||||||
|
StorageProvider,
|
||||||
|
} from '@afilmory/builder'
|
||||||
|
import type { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
|
||||||
|
import type { DataSyncLogPayload } from 'core/modules/infrastructure/data-sync/data-sync.types'
|
||||||
|
|
||||||
|
type BuilderDebugResultType = 'new' | 'processed' | 'skipped' | 'failed'
|
||||||
|
export type BuilderDebugProgressEvent =
|
||||||
|
| {
|
||||||
|
type: 'start'
|
||||||
|
payload: {
|
||||||
|
storageKey: string
|
||||||
|
filename: string
|
||||||
|
contentType: string | null
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'log'
|
||||||
|
payload: DataSyncLogPayload
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'complete'
|
||||||
|
payload: {
|
||||||
|
storageKey: string
|
||||||
|
resultType: BuilderDebugResultType
|
||||||
|
manifestItem: PhotoManifestItem | null
|
||||||
|
thumbnailUrl?: string | null
|
||||||
|
filesDeleted: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error'
|
||||||
|
payload: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type UploadedDebugFile = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
contentType: string | null
|
||||||
|
buffer: Buffer
|
||||||
|
}
|
||||||
|
export type StorageResolution = {
|
||||||
|
builder: ReturnType<PhotoBuilderService['createBuilder']>
|
||||||
|
builderConfig: BuilderConfig
|
||||||
|
storageConfig: StorageConfig
|
||||||
|
storageManager: StorageManager
|
||||||
|
}
|
||||||
|
export class InMemoryDebugStorageProvider implements StorageProvider {
|
||||||
|
private readonly files = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
buffer: Buffer
|
||||||
|
metadata: StorageObject
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
async getFile(key: string): Promise<Buffer | null> {
|
||||||
|
return this.files.get(key)?.buffer ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async listImages(): Promise<StorageObject[]> {
|
||||||
|
return Array.from(this.files.values()).map((entry) => entry.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllFiles(): Promise<StorageObject[]> {
|
||||||
|
return this.listImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
generatePublicUrl(key: string): string {
|
||||||
|
return `debug://${encodeURIComponent(key)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLivePhotos(): Map<string, StorageObject> {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(key: string): Promise<void> {
|
||||||
|
this.files.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(key: string, data: Buffer): Promise<StorageObject> {
|
||||||
|
const normalizedKey = this.normalizeKey(key)
|
||||||
|
const metadata: StorageObject = {
|
||||||
|
key: normalizedKey,
|
||||||
|
size: data.length,
|
||||||
|
lastModified: new Date(),
|
||||||
|
etag: randomUUID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.files.set(normalizedKey, {
|
||||||
|
buffer: data,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKey(key: string): string {
|
||||||
|
return key.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import type { BuilderConfig, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder'
|
||||||
|
import { resolveBuilderConfig } from '@afilmory/builder'
|
||||||
|
import type { ThumbnailPluginData } from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY,
|
||||||
|
THUMBNAIL_PLUGIN_DATA_KEY,
|
||||||
|
} from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
|
||||||
|
import { ContextParam, Controller, createLogger, Post } from '@afilmory/framework'
|
||||||
|
import { BizException, ErrorCode } from 'core/errors'
|
||||||
|
import { Roles } from 'core/guards/roles.decorator'
|
||||||
|
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||||
|
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
|
||||||
|
import { runWithBuilderLogRelay } from 'core/modules/infrastructure/data-sync/builder-log-relay'
|
||||||
|
import type { DataSyncProgressEmitter } from 'core/modules/infrastructure/data-sync/data-sync.types'
|
||||||
|
import { createProgressSseResponse } from 'core/modules/shared/http/sse'
|
||||||
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
|
import type { BuilderDebugProgressEvent, StorageResolution, UploadedDebugFile } from './InMemoryDebugStorageProvider'
|
||||||
|
import { InMemoryDebugStorageProvider } from './InMemoryDebugStorageProvider'
|
||||||
|
|
||||||
|
const DEBUG_STORAGE_PREFIX = '.afilmory/debug'
|
||||||
|
const DEBUG_STORAGE_PROVIDER = 'super-admin-debug-storage'
|
||||||
|
|
||||||
|
@Controller('super-admin/builder')
|
||||||
|
@Roles('superadmin')
|
||||||
|
export class SuperAdminBuilderDebugController {
|
||||||
|
private readonly logger = createLogger('SuperAdminBuilderDebugController')
|
||||||
|
|
||||||
|
constructor(private readonly photoBuilderService: PhotoBuilderService) {}
|
||||||
|
|
||||||
|
@Post('debug')
|
||||||
|
@BypassResponseTransform()
|
||||||
|
async runDebug(@ContextParam() context: Context): Promise<Response> {
|
||||||
|
const payload = await context.req.parseBody()
|
||||||
|
const file = await this.extractFirstFile(payload)
|
||||||
|
if (!file) {
|
||||||
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||||
|
message: '请上传一张待调试的图片文件',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.contentType && !file.contentType.toLowerCase().startsWith('image/')) {
|
||||||
|
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||||
|
message: '调试通道仅支持图片文件',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = await this.createDebugStorageContext()
|
||||||
|
return createProgressSseResponse<BuilderDebugProgressEvent>({
|
||||||
|
context,
|
||||||
|
handler: async ({ sendEvent }) => {
|
||||||
|
const logEmitter: DataSyncProgressEmitter = async (event) => {
|
||||||
|
if (event.type !== 'log') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendEvent({ type: 'log', payload: event.payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executeDebugRun({
|
||||||
|
...storage,
|
||||||
|
file,
|
||||||
|
sendEvent,
|
||||||
|
logEmitter,
|
||||||
|
})
|
||||||
|
|
||||||
|
sendEvent({
|
||||||
|
type: 'complete',
|
||||||
|
payload: {
|
||||||
|
storageKey: result.storageKey,
|
||||||
|
resultType: result.resultType,
|
||||||
|
manifestItem: result.manifestItem,
|
||||||
|
thumbnailUrl: result.thumbnailUrl,
|
||||||
|
filesDeleted: result.filesDeleted,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
this.logger.error('Builder debug run failed', error)
|
||||||
|
sendEvent({ type: 'error', payload: { message } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDebugStorageContext(): Promise<StorageResolution> {
|
||||||
|
const builderConfig = resolveBuilderConfig({})
|
||||||
|
|
||||||
|
const storageConfig: StorageConfig = { provider: DEBUG_STORAGE_PROVIDER }
|
||||||
|
|
||||||
|
if (!builderConfig.user || typeof builderConfig.user !== 'object') {
|
||||||
|
builderConfig.user = {} as NonNullable<BuilderConfig['user']>
|
||||||
|
}
|
||||||
|
|
||||||
|
builderConfig.user.storage = storageConfig
|
||||||
|
|
||||||
|
const builder = this.photoBuilderService.createBuilder(builderConfig)
|
||||||
|
builder.registerStorageProvider(DEBUG_STORAGE_PROVIDER, () => new InMemoryDebugStorageProvider())
|
||||||
|
this.photoBuilderService.applyStorageConfig(builder, storageConfig)
|
||||||
|
|
||||||
|
return {
|
||||||
|
builder,
|
||||||
|
builderConfig,
|
||||||
|
storageConfig,
|
||||||
|
storageManager: builder.getStorageManager(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeDebugRun(
|
||||||
|
params: StorageResolution & {
|
||||||
|
file: UploadedDebugFile
|
||||||
|
sendEvent: (event: BuilderDebugProgressEvent) => void
|
||||||
|
logEmitter: DataSyncProgressEmitter
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { builder, builderConfig, storageManager, file, sendEvent, logEmitter } = params
|
||||||
|
const cleanupKeys = new Set<string>()
|
||||||
|
|
||||||
|
const tempKey = this.createTemporaryStorageKey(file.name)
|
||||||
|
const uploaded = await storageManager.uploadFile(tempKey, file.buffer, {
|
||||||
|
contentType: file.contentType ?? undefined,
|
||||||
|
})
|
||||||
|
const normalizedObject = this.normalizeStorageObjectKey(uploaded, tempKey)
|
||||||
|
cleanupKeys.add(normalizedObject.key)
|
||||||
|
|
||||||
|
sendEvent({
|
||||||
|
type: 'start',
|
||||||
|
payload: {
|
||||||
|
storageKey: normalizedObject.key,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.contentType,
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let processed: Awaited<ReturnType<typeof this.photoBuilderService.processPhotoFromStorageObject>> | null = null
|
||||||
|
let filesDeleted = false
|
||||||
|
try {
|
||||||
|
processed = await runWithBuilderLogRelay(logEmitter, () =>
|
||||||
|
this.photoBuilderService.processPhotoFromStorageObject(normalizedObject, {
|
||||||
|
builder,
|
||||||
|
builderConfig,
|
||||||
|
processorOptions: {
|
||||||
|
isForceMode: true,
|
||||||
|
isForceManifest: true,
|
||||||
|
isForceThumbnails: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const thumbnailKey = this.resolveThumbnailStorageKey(processed?.pluginData)
|
||||||
|
if (thumbnailKey) {
|
||||||
|
cleanupKeys.add(thumbnailKey)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
filesDeleted = await this.cleanupDebugArtifacts(storageManager, cleanupKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageKey: normalizedObject.key,
|
||||||
|
resultType: processed?.type ?? 'failed',
|
||||||
|
manifestItem: processed?.item ?? null,
|
||||||
|
thumbnailUrl: processed?.item?.thumbnailUrl ?? null,
|
||||||
|
filesDeleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractFirstFile(payload: Record<string, unknown>): Promise<UploadedDebugFile | null> {
|
||||||
|
const files: File[] = []
|
||||||
|
|
||||||
|
for (const value of Object.values(payload)) {
|
||||||
|
if (value instanceof File) {
|
||||||
|
files.push(value)
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
if (entry instanceof File) {
|
||||||
|
files.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[0]
|
||||||
|
if (!file) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
return {
|
||||||
|
name: file.name || 'debug-upload',
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.type || null,
|
||||||
|
buffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTemporaryStorageKey(filename: string): string {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
const safeExt = ext || '.jpg'
|
||||||
|
const baseName = `${Date.now()}-${randomUUID()}`
|
||||||
|
return this.joinSegments(DEBUG_STORAGE_PREFIX, `${baseName}${safeExt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStorageObjectKey(object: StorageObject, fallbackKey: string): StorageObject {
|
||||||
|
const normalizedKey = this.normalizeKeyPath(object?.key ?? fallbackKey)
|
||||||
|
if (normalizedKey === object?.key) {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...object,
|
||||||
|
key: normalizedKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKeyPath(raw: string | undefined | null): string {
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const segments = raw.split(/[\\/]+/)
|
||||||
|
const safeSegments: string[] = []
|
||||||
|
for (const segment of segments) {
|
||||||
|
const trimmed = segment.trim()
|
||||||
|
if (!trimmed || trimmed === '.' || trimmed === '..') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safeSegments.push(trimmed)
|
||||||
|
}
|
||||||
|
return safeSegments.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupDebugArtifacts(storageManager: StorageManager, keys: Set<string>): Promise<boolean> {
|
||||||
|
let success = true
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
await storageManager.deleteFile(key)
|
||||||
|
} catch (error) {
|
||||||
|
success = false
|
||||||
|
this.logger.warn(`Failed to delete debug artifact ${key}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveThumbnailStorageKey(pluginData?: Record<string, unknown>): string | null {
|
||||||
|
if (!pluginData || !pluginData[THUMBNAIL_PLUGIN_DATA_KEY]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = pluginData[THUMBNAIL_PLUGIN_DATA_KEY] as ThumbnailPluginData
|
||||||
|
if (!data?.fileName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotePrefix = this.joinSegments(DEFAULT_THUMBNAIL_DIRECTORY)
|
||||||
|
|
||||||
|
return this.joinSegments(remotePrefix, data.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private joinSegments(...segments: Array<string | null | undefined>): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
for (const raw of segments) {
|
||||||
|
if (!raw) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const normalized = raw
|
||||||
|
.replaceAll('\\', '/')
|
||||||
|
.replaceAll(/^\/+|\/+$/g, '')
|
||||||
|
.trim()
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
parts.push(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@afilmory/framework'
|
import { Module } from '@afilmory/framework'
|
||||||
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
|
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
|
||||||
|
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
|
||||||
|
|
||||||
import { SuperAdminSettingController } from './super-admin.controller'
|
import { SuperAdminBuilderDebugController } from './super-admin-builder.controller'
|
||||||
|
import { SuperAdminSettingController } from './super-admin-settings.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SystemSettingModule],
|
imports: [SystemSettingModule],
|
||||||
controllers: [SuperAdminSettingController],
|
controllers: [SuperAdminSettingController, SuperAdminBuilderDebugController],
|
||||||
|
providers: [PhotoBuilderService],
|
||||||
})
|
})
|
||||||
export class SuperAdminModule {}
|
export class SuperAdminModule {}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { env } from '@afilmory/env'
|
|
||||||
import { HttpContext } from '@afilmory/framework'
|
import { HttpContext } from '@afilmory/framework'
|
||||||
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
|
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
|
||||||
import { BizException, ErrorCode } from 'core/errors'
|
import { BizException, ErrorCode } from 'core/errors'
|
||||||
@@ -13,8 +12,6 @@ import { TenantService } from './tenant.service'
|
|||||||
import type { TenantAggregate, TenantContext } from './tenant.types'
|
import type { TenantAggregate, TenantContext } from './tenant.types'
|
||||||
import { extractTenantSlugFromHost } from './tenant-host.utils'
|
import { extractTenantSlugFromHost } from './tenant-host.utils'
|
||||||
|
|
||||||
const HEADER_TENANT_ID = 'x-tenant-id'
|
|
||||||
const HEADER_TENANT_SLUG = 'x-tenant-slug'
|
|
||||||
const ROOT_TENANT_PATH_PREFIXES = [
|
const ROOT_TENANT_PATH_PREFIXES = [
|
||||||
'/api/super-admin',
|
'/api/super-admin',
|
||||||
'/api/settings',
|
'/api/settings',
|
||||||
@@ -24,7 +21,6 @@ const ROOT_TENANT_PATH_PREFIXES = [
|
|||||||
|
|
||||||
export interface TenantResolutionOptions {
|
export interface TenantResolutionOptions {
|
||||||
throwOnMissing?: boolean
|
throwOnMissing?: boolean
|
||||||
setResponseHeaders?: boolean
|
|
||||||
skipInitializationCheck?: boolean
|
skipInitializationCheck?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +37,6 @@ export class TenantContextResolver {
|
|||||||
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
|
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
|
||||||
const existing = this.getExistingContext()
|
const existing = this.getExistingContext()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (options.setResponseHeaders !== false) {
|
|
||||||
this.applyTenantHeaders(context, existing)
|
|
||||||
}
|
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,36 +55,28 @@ export class TenantContextResolver {
|
|||||||
|
|
||||||
this.log.debug(`Forwarded host: ${forwardedHost}, Host header: ${hostHeader}, Origin: ${origin}, Host: ${host}`)
|
this.log.debug(`Forwarded host: ${forwardedHost}, Host header: ${hostHeader}, Origin: ${origin}, Host: ${host}`)
|
||||||
|
|
||||||
const tenantId = this.normalizeString(context.req.header(HEADER_TENANT_ID))
|
|
||||||
const tenantSlugHeader = this.normalizeSlug(context.req.header(HEADER_TENANT_SLUG))
|
|
||||||
|
|
||||||
const baseDomain = await this.getBaseDomain()
|
const baseDomain = await this.getBaseDomain()
|
||||||
|
|
||||||
let derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
|
let derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
|
||||||
if (!derivedSlug && host && this.isBaseDomainHost(host, baseDomain)) {
|
|
||||||
derivedSlug = ROOT_TENANT_SLUG
|
|
||||||
}
|
|
||||||
if (!derivedSlug && this.isRootTenantPath(context.req.path)) {
|
if (!derivedSlug && this.isRootTenantPath(context.req.path)) {
|
||||||
derivedSlug = ROOT_TENANT_SLUG
|
derivedSlug = ROOT_TENANT_SLUG
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlug = derivedSlug ?? tenantSlugHeader ?? null
|
const requestedSlug = derivedSlug ?? null
|
||||||
if (!derivedSlug) {
|
|
||||||
derivedSlug = tenantSlugHeader
|
|
||||||
}
|
|
||||||
this.log.verbose(
|
this.log.verbose(
|
||||||
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`,
|
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`,
|
||||||
)
|
)
|
||||||
|
|
||||||
let tenantContext = await this.tenantService.resolve(
|
let tenantContext: TenantContext | null = null
|
||||||
{
|
if (derivedSlug) {
|
||||||
tenantId,
|
tenantContext = await this.tenantService.resolve(
|
||||||
slug: derivedSlug,
|
{
|
||||||
},
|
slug: derivedSlug,
|
||||||
true,
|
},
|
||||||
)
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!tenantContext && this.shouldFallbackToPlaceholder(tenantId, derivedSlug)) {
|
if (!tenantContext && this.shouldFallbackToPlaceholder(derivedSlug)) {
|
||||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||||
tenantContext = this.asTenantContext(placeholder, true, requestedSlug)
|
tenantContext = this.asTenantContext(placeholder, true, requestedSlug)
|
||||||
this.log.verbose(
|
this.log.verbose(
|
||||||
@@ -106,67 +91,15 @@ export class TenantContextResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tenantContext) {
|
if (!tenantContext) {
|
||||||
if (options.throwOnMissing && (tenantId || derivedSlug)) {
|
if (options.throwOnMissing && derivedSlug) {
|
||||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.setResponseHeaders !== false) {
|
|
||||||
this.applyTenantHeaders(context, tenantContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tenantContext
|
return tenantContext
|
||||||
}
|
}
|
||||||
|
|
||||||
private isBaseDomainHost(host: string, baseDomain: string): boolean {
|
|
||||||
const parsed = this.parseHost(host)
|
|
||||||
if (!parsed.hostname) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedHost = parsed.hostname.trim().toLowerCase()
|
|
||||||
const normalizedBase = baseDomain.trim().toLowerCase()
|
|
||||||
|
|
||||||
if (normalizedBase === 'localhost') {
|
|
||||||
return normalizedHost === 'localhost' && this.matchesServerPort(parsed.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedHost === normalizedBase && this.matchesServerPort(parsed.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseHost(host: string): { hostname: string | null; port: string | null } {
|
|
||||||
if (!host) {
|
|
||||||
return { hostname: null, port: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host.startsWith('[')) {
|
|
||||||
// IPv6 literal (e.g. [::1]:3000)
|
|
||||||
const closingIndex = host.indexOf(']')
|
|
||||||
if (closingIndex === -1) {
|
|
||||||
return { hostname: host, port: null }
|
|
||||||
}
|
|
||||||
const hostname = host.slice(1, closingIndex)
|
|
||||||
const portSegment = host.slice(closingIndex + 1)
|
|
||||||
const port = portSegment.startsWith(':') ? portSegment.slice(1) : null
|
|
||||||
return { hostname, port: port && port.length > 0 ? port : null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [hostname, port] = host.split(':', 2)
|
|
||||||
return { hostname: hostname ?? null, port: port ?? null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesServerPort(port: string | null): boolean {
|
|
||||||
if (!port) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const parsed = Number.parseInt(port, 10)
|
|
||||||
if (Number.isNaN(parsed)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return parsed === env.PORT
|
|
||||||
}
|
|
||||||
|
|
||||||
private isRootTenantPath(path: string | undefined): boolean {
|
private isRootTenantPath(path: string | undefined): boolean {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return false
|
return false
|
||||||
@@ -185,14 +118,6 @@ export class TenantContextResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTenantHeaders(context: Context, tenantContext: TenantContext): void {
|
|
||||||
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
|
|
||||||
const effectiveSlug = tenantContext.requestedSlug ?? tenantContext.tenant.slug
|
|
||||||
if (effectiveSlug) {
|
|
||||||
context.header(HEADER_TENANT_SLUG, effectiveSlug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getBaseDomain(): Promise<string> {
|
private async getBaseDomain(): Promise<string> {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
return 'localhost'
|
return 'localhost'
|
||||||
@@ -223,21 +148,8 @@ export class TenantContextResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeString(value: string | null | undefined): string | undefined {
|
private shouldFallbackToPlaceholder(slug?: string | null): boolean {
|
||||||
if (!value) {
|
return !slug
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const trimmed = value.trim()
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeSlug(value: string | null | undefined): string | undefined {
|
|
||||||
const normalized = this.normalizeString(value)
|
|
||||||
return normalized ? normalized.toLowerCase() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldFallbackToPlaceholder(tenantId?: string, slug?: string): boolean {
|
|
||||||
return !(tenantId && tenantId.length > 0) && !(slug && slug.length > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private asTenantContext(
|
private asTenantContext(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { tenants, tenantStatusEnum } from '@afilmory/db'
|
import type { tenants } from '@afilmory/db'
|
||||||
|
|
||||||
export type TenantRecord = typeof tenants.$inferSelect
|
export type TenantRecord = typeof tenants.$inferSelect
|
||||||
export type TenantStatus = (typeof tenantStatusEnum.enumValues)[number]
|
|
||||||
|
|
||||||
export interface TenantAggregate {
|
export interface TenantAggregate {
|
||||||
tenant: TenantRecord
|
tenant: TenantRecord
|
||||||
@@ -16,8 +15,3 @@ export interface TenantResolutionInput {
|
|||||||
tenantId?: string | null
|
tenantId?: string | null
|
||||||
slug?: string | null
|
slug?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantCacheEntry {
|
|
||||||
aggregate: TenantAggregate
|
|
||||||
cachedAt: number
|
|
||||||
}
|
|
||||||
|
|||||||
129
be/apps/core/src/modules/shared/http/sse.ts
Normal file
129
be/apps/core/src/modules/shared/http/sse.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
|
export interface CreateProgressSseResponseOptions<TEvent> {
|
||||||
|
context: Context
|
||||||
|
eventName?: string
|
||||||
|
heartbeatIntervalMs?: number
|
||||||
|
handler: (helpers: SseHandlerHelpers<TEvent>) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseHandlerHelpers<TEvent> {
|
||||||
|
sendEvent: (event: TEvent) => void
|
||||||
|
sendChunk: (chunk: string) => void
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EVENT_NAME = 'progress'
|
||||||
|
const DEFAULT_HEARTBEAT_MS = 15_000
|
||||||
|
|
||||||
|
export function createProgressSseResponse<TEvent>({
|
||||||
|
context,
|
||||||
|
eventName = DEFAULT_EVENT_NAME,
|
||||||
|
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
|
||||||
|
handler,
|
||||||
|
}: CreateProgressSseResponseOptions<TEvent>): Response {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
let cleanup: (() => void) | undefined
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start: (controller) => {
|
||||||
|
let closed = false
|
||||||
|
const rawRequest = context.req.raw
|
||||||
|
const abortSignal = rawRequest.signal
|
||||||
|
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | undefined
|
||||||
|
let abortHandler: (() => void) | undefined
|
||||||
|
|
||||||
|
const cleanupInternal = () => {
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
heartbeat = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortHandler) {
|
||||||
|
abortSignal.removeEventListener('abort', abortHandler)
|
||||||
|
abortHandler = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
closed = true
|
||||||
|
try {
|
||||||
|
controller.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendChunk = (chunk: string) => {
|
||||||
|
if (closed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(chunk))
|
||||||
|
} catch {
|
||||||
|
cleanupInternal()
|
||||||
|
cleanup = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEvent = (event: TEvent) => {
|
||||||
|
sendChunk(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
sendChunk(`: keep-alive ${new Date().toISOString()}\n\n`)
|
||||||
|
}, heartbeatIntervalMs)
|
||||||
|
|
||||||
|
abortHandler = () => {
|
||||||
|
const currentCleanup = cleanup
|
||||||
|
cleanup = undefined
|
||||||
|
currentCleanup?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
abortSignal.addEventListener('abort', abortHandler)
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
cleanupInternal()
|
||||||
|
cleanup = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
sendChunk(': connected\n\n')
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await handler({
|
||||||
|
sendEvent,
|
||||||
|
sendChunk,
|
||||||
|
abortSignal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSE handler failed', error)
|
||||||
|
} finally {
|
||||||
|
const currentCleanup = cleanup
|
||||||
|
cleanup = undefined
|
||||||
|
currentCleanup?.()
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error('Unhandled SSE handler error', error)
|
||||||
|
const currentCleanup = cleanup
|
||||||
|
cleanup = undefined
|
||||||
|
currentCleanup?.()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
const currentCleanup = cleanup
|
||||||
|
cleanup = undefined
|
||||||
|
currentCleanup?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@afilmory/ui'
|
||||||
|
import { clsxm } from '@afilmory/utils'
|
||||||
|
import { ChevronDown, LogOut } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { usePageRedirect } from '~/hooks/usePageRedirect'
|
||||||
|
import type { BetterAuthUser } from '~/modules/auth/types'
|
||||||
|
|
||||||
|
interface SuperAdminUserMenuProps {
|
||||||
|
user: BetterAuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuperAdminUserMenu({ user }: SuperAdminUserMenuProps) {
|
||||||
|
const { logout } = usePageRedirect()
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (isLoggingOut) return
|
||||||
|
|
||||||
|
setIsLoggingOut(true)
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error)
|
||||||
|
setIsLoggingOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsxm(
|
||||||
|
'flex items-center gap-2.5 rounded-lg px-2 py-1.5',
|
||||||
|
'transition-all duration-200',
|
||||||
|
'hover:bg-fill/50',
|
||||||
|
'active:scale-[0.98]',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* User Avatar */}
|
||||||
|
<div className="relative">
|
||||||
|
{user.image ? (
|
||||||
|
<img src={user.image} alt={user.name || user.email} className="size-7 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="bg-accent/20 text-accent flex size-7 items-center justify-center rounded-full text-xs font-medium">
|
||||||
|
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-text text-[13px] leading-tight font-medium">{user.name || user.email}</div>
|
||||||
|
<div className="text-text-tertiary text-[11px] leading-tight capitalize">{user.role}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron Icon */}
|
||||||
|
<ChevronDown
|
||||||
|
className={clsxm('text-text-tertiary size-3.5 transition-transform duration-200', isOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="border-fill-tertiary bg-background w-56 shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'none',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-text text-sm font-medium">{user.name || 'Super Admin'}</p>
|
||||||
|
<p className="text-text-tertiary text-xs">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} icon={<LogOut className="text-red size-4" />}>
|
||||||
|
<span className="text-red">{isLoggingOut ? 'Logging out...' : 'Log out'}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,9 +33,17 @@ export function useRegisterTenant() {
|
|||||||
|
|
||||||
const response = await registerTenant(payload)
|
const response = await registerTenant(payload)
|
||||||
|
|
||||||
const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null
|
let finalSlug = payload.tenant.slug?.trim() ?? ''
|
||||||
const submittedSlug = payload.tenant.slug?.trim().toLowerCase() ?? ''
|
|
||||||
const finalSlug = headerSlug && headerSlug.length > 0 ? headerSlug : submittedSlug
|
try {
|
||||||
|
const data = (await response.clone().json()) as { tenant?: { slug?: string } } | null
|
||||||
|
const slugFromResponse = data?.tenant?.slug?.trim()
|
||||||
|
if (slugFromResponse) {
|
||||||
|
finalSlug = slugFromResponse
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors; fall back to submitted slug
|
||||||
|
}
|
||||||
|
|
||||||
if (!finalSlug) {
|
if (!finalSlug) {
|
||||||
throw new Error('Registration succeeded but the workspace slug could not be determined.')
|
throw new Error('Registration succeeded but the workspace slug could not be determined.')
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { coreApi } from '~/lib/api-client'
|
import { coreApi, coreApiBaseURL } from '~/lib/api-client'
|
||||||
|
import { camelCaseKeys } from '~/lib/case'
|
||||||
|
|
||||||
import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from './types'
|
import type {
|
||||||
|
BuilderDebugProgressEvent,
|
||||||
|
BuilderDebugResult,
|
||||||
|
SuperAdminSettingsResponse,
|
||||||
|
UpdateSuperAdminSettingsPayload,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings'
|
const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings'
|
||||||
|
const STABLE_NEWLINE = /\r?\n/
|
||||||
|
|
||||||
|
type RunBuilderDebugOptions = {
|
||||||
|
signal?: AbortSignal
|
||||||
|
onEvent?: (event: BuilderDebugProgressEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSuperAdminSettings() {
|
export async function fetchSuperAdminSettings() {
|
||||||
return await coreApi<SuperAdminSettingsResponse>(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, {
|
return await coreApi<SuperAdminSettingsResponse>(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, {
|
||||||
@@ -19,3 +31,114 @@ export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettings
|
|||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugOptions): Promise<BuilderDebugResult> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await fetch(`${coreApiBaseURL}/super-admin/builder/debug`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'text/event-stream',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`调试请求失败:${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
let finalResult: BuilderDebugResult | null = null
|
||||||
|
let lastErrorMessage: string | null = null
|
||||||
|
|
||||||
|
const stageEvent = (rawEvent: string) => {
|
||||||
|
const lines = rawEvent.split(STABLE_NEWLINE)
|
||||||
|
let eventName: string | null = null
|
||||||
|
const dataLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith(':')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventName = line.slice(6).trim()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventName || dataLines.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName !== 'progress') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = dataLines.join('\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = camelCaseKeys<BuilderDebugProgressEvent>(JSON.parse(data))
|
||||||
|
options?.onEvent?.(parsed)
|
||||||
|
|
||||||
|
if (parsed.type === 'complete') {
|
||||||
|
finalResult = parsed.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'error') {
|
||||||
|
lastErrorMessage = parsed.payload.message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse builder debug event', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
let boundary = buffer.indexOf('\n\n')
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const rawEvent = buffer.slice(0, boundary)
|
||||||
|
buffer = buffer.slice(boundary + 2)
|
||||||
|
stageEvent(rawEvent)
|
||||||
|
boundary = buffer.indexOf('\n\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim().length > 0) {
|
||||||
|
stageEvent(buffer)
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastErrorMessage) {
|
||||||
|
throw new Error(lastErrorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalResult) {
|
||||||
|
throw new Error('调试过程中未收到最终结果,连接已终止。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return camelCaseKeys<BuilderDebugResult>(finalResult)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||||
|
|
||||||
|
import type { PhotoSyncLogLevel } from '../photos/types'
|
||||||
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
|
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
|
||||||
|
|
||||||
export type SuperAdminSettingField = string
|
export type SuperAdminSettingField = string
|
||||||
@@ -27,3 +30,41 @@ export type SuperAdminSettingsResponse =
|
|||||||
export type UpdateSuperAdminSettingsPayload = Partial<
|
export type UpdateSuperAdminSettingsPayload = Partial<
|
||||||
Record<SuperAdminSettingField, SchemaFormValue | null | undefined>
|
Record<SuperAdminSettingField, SchemaFormValue | null | undefined>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export type BuilderDebugProgressEvent =
|
||||||
|
| {
|
||||||
|
type: 'start'
|
||||||
|
payload: {
|
||||||
|
storageKey: string
|
||||||
|
filename: string
|
||||||
|
contentType: string | null
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'log'
|
||||||
|
payload: {
|
||||||
|
level: PhotoSyncLogLevel
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'complete'
|
||||||
|
payload: BuilderDebugResult
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error'
|
||||||
|
payload: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuilderDebugResult {
|
||||||
|
storageKey: string
|
||||||
|
resultType: 'new' | 'processed' | 'skipped' | 'failed'
|
||||||
|
manifestItem: PhotoManifestItem | null
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
filesDeleted: boolean
|
||||||
|
}
|
||||||
|
|||||||
512
be/apps/dashboard/src/pages/superadmin/debug.tsx
Normal file
512
be/apps/dashboard/src/pages/superadmin/debug.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import { Button } from '@afilmory/ui'
|
||||||
|
import { clsxm, Spring } from '@afilmory/utils'
|
||||||
|
import { Copy, Play, Square, Upload } from 'lucide-react'
|
||||||
|
import { m } from 'motion/react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||||
|
import { getRequestErrorMessage } from '~/lib/errors'
|
||||||
|
import type { PhotoSyncLogLevel } from '~/modules/photos/types'
|
||||||
|
import type { BuilderDebugProgressEvent, BuilderDebugResult } from '~/modules/super-admin'
|
||||||
|
import { runBuilderDebugTest } from '~/modules/super-admin'
|
||||||
|
|
||||||
|
const MAX_LOG_ENTRIES = 300
|
||||||
|
|
||||||
|
const LEVEL_THEME: Record<PhotoSyncLogLevel, string> = {
|
||||||
|
info: 'border-sky-500/30 bg-sky-500/10 text-sky-100',
|
||||||
|
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100',
|
||||||
|
warn: 'border-amber-500/30 bg-amber-500/10 text-amber-100',
|
||||||
|
error: 'border-rose-500/30 bg-rose-500/10 text-rose-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<RunStatus, { label: string; className: string }> = {
|
||||||
|
idle: { label: '就绪', className: 'text-text-tertiary' },
|
||||||
|
running: { label: '调试中', className: 'text-accent' },
|
||||||
|
success: { label: '已完成', className: 'text-emerald-400' },
|
||||||
|
error: { label: '失败', className: 'text-rose-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunStatus = 'idle' | 'running' | 'success' | 'error'
|
||||||
|
type DebugStartPayload = Extract<BuilderDebugProgressEvent, { type: 'start' }>['payload']
|
||||||
|
|
||||||
|
type DebugLogEntry =
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: 'start' | 'complete' | 'error'
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: 'log'
|
||||||
|
level: PhotoSyncLogLevel
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
try {
|
||||||
|
return timeFormatter.format(timestamp)
|
||||||
|
} catch {
|
||||||
|
return '--:--:--'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined | null): string {
|
||||||
|
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return '0 B'
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
const value = bytes / 1024 ** exponent
|
||||||
|
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={Spring.presets.smooth}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-text text-2xl font-semibold">Builder 调试工具</h1>
|
||||||
|
<p className="text-text-tertiary text-sm">
|
||||||
|
该工具用于单张图片的 Builder 管线验收。调试过程中不会写入数据库,所有上传与生成的文件会在任务完成后立刻清理。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<BuilderDebugConsole />
|
||||||
|
</m.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuilderDebugConsole() {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [runStatus, setRunStatus] = useState<RunStatus>('idle')
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [logEntries, setLogEntries] = useState<DebugLogEntry[]>([])
|
||||||
|
const [result, setResult] = useState<BuilderDebugResult | null>(null)
|
||||||
|
const [runMeta, setRunMeta] = useState<DebugStartPayload | null>(null)
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const logViewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const isRunning = runStatus === 'running'
|
||||||
|
const manifestJson = useMemo(
|
||||||
|
() => (result?.manifestItem ? JSON.stringify(result.manifestItem, null, 2) : null),
|
||||||
|
[result],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logViewportRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logViewportRef.current.scrollTop = logViewportRef.current.scrollHeight
|
||||||
|
}, [logEntries])
|
||||||
|
|
||||||
|
const appendLogEntry = useCallback((event: BuilderDebugProgressEvent) => {
|
||||||
|
setLogEntries((prev) => {
|
||||||
|
const entry = buildLogEntry(event)
|
||||||
|
if (!entry) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next = [...prev, entry]
|
||||||
|
if (next.length > MAX_LOG_ENTRIES) {
|
||||||
|
return next.slice(-MAX_LOG_ENTRIES)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleProgressEvent = useCallback(
|
||||||
|
(event: BuilderDebugProgressEvent) => {
|
||||||
|
appendLogEntry(event)
|
||||||
|
|
||||||
|
if (event.type === 'start') {
|
||||||
|
setRunMeta(event.payload)
|
||||||
|
setErrorMessage(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'complete') {
|
||||||
|
setResult(event.payload)
|
||||||
|
setRunStatus('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'error') {
|
||||||
|
setErrorMessage(event.payload.message)
|
||||||
|
setRunStatus('error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendLogEntry],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
const file = event.target.files?.[0] ?? null
|
||||||
|
event.target.value = ''
|
||||||
|
setSelectedFile(file)
|
||||||
|
setResult(null)
|
||||||
|
setErrorMessage(null)
|
||||||
|
setRunMeta(null)
|
||||||
|
setLogEntries([])
|
||||||
|
setRunStatus('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearFile = () => {
|
||||||
|
setSelectedFile(null)
|
||||||
|
setResult(null)
|
||||||
|
setRunMeta(null)
|
||||||
|
setLogEntries([])
|
||||||
|
setRunStatus('idle')
|
||||||
|
setErrorMessage(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
toast.info('请选择需要调试的图片文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
abortControllerRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortControllerRef.current = controller
|
||||||
|
|
||||||
|
setRunStatus('running')
|
||||||
|
setErrorMessage(null)
|
||||||
|
setResult(null)
|
||||||
|
setLogEntries([])
|
||||||
|
setRunMeta(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const debugResult = await runBuilderDebugTest(selectedFile, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: handleProgressEvent,
|
||||||
|
})
|
||||||
|
|
||||||
|
setResult(debugResult)
|
||||||
|
setRunStatus('success')
|
||||||
|
toast.success('调试完成', { description: 'Builder 管线执行成功,产物已清理。' })
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
toast.info('调试已取消')
|
||||||
|
setRunStatus('idle')
|
||||||
|
} else {
|
||||||
|
const message = getRequestErrorMessage(error, '调试失败,请检查后重试。')
|
||||||
|
setErrorMessage(message)
|
||||||
|
setRunStatus('error')
|
||||||
|
toast.error('调试失败', { description: message })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortControllerRef.current = null
|
||||||
|
}
|
||||||
|
}, [handleProgressEvent, selectedFile])
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!isRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abortControllerRef.current?.abort()
|
||||||
|
abortControllerRef.current = null
|
||||||
|
setRunStatus('idle')
|
||||||
|
setErrorMessage('调试已被手动取消。')
|
||||||
|
setLogEntries((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'error',
|
||||||
|
message: '手动取消调试任务',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
toast.info('调试已取消')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyManifest = async () => {
|
||||||
|
if (!manifestJson) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(manifestJson)
|
||||||
|
toast.success('已复制 manifest 数据')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('复制失败', {
|
||||||
|
description: getRequestErrorMessage(error, '请手动复制内容'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,360px)_1fr]">
|
||||||
|
<LinearBorderPanel className="bg-background-tertiary/70 relative overflow-hidden rounded-xl p-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text text-base font-semibold">调试输入</p>
|
||||||
|
<p className="text-text-tertiary text-xs">选择一张原始图片,系统将模拟 Builder 处理链路。</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={runStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="builder-debug-file"
|
||||||
|
className={clsxm(
|
||||||
|
'border-border/30 bg-fill/10 flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed px-4 py-8 text-center transition hover:border-accent/40 hover:bg-accent/5',
|
||||||
|
isRunning && 'pointer-events-none opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className="mb-3 h-6 w-6 text-text" />
|
||||||
|
<p className="text-text text-sm font-medium">
|
||||||
|
{selectedFile ? selectedFile.name : '点击或拖拽图片到此区域'}
|
||||||
|
</p>
|
||||||
|
<p className="text-text-tertiary mt-1 text-xs">仅支持单张图片,最大 25 MB</p>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="builder-debug-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isRunning}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedFile ? (
|
||||||
|
<div className="text-text-secondary flex items-center justify-between rounded-lg bg-background-secondary/80 px-3 py-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-text text-sm font-medium">{selectedFile.name}</p>
|
||||||
|
<p className="text-text-tertiary text-xs mt-0.5">
|
||||||
|
{formatBytes(selectedFile.size)} · {selectedFile.type || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="xs" onClick={handleClearFile} disabled={isRunning}>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" onClick={handleStart} disabled={!selectedFile || isRunning}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
启动调试
|
||||||
|
</Button>
|
||||||
|
{isRunning ? (
|
||||||
|
<Button type="button" variant="ghost" onClick={handleCancel}>
|
||||||
|
<Square className="mr-2 h-4 w-4" />
|
||||||
|
取消调试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-text-tertiary text-xs">
|
||||||
|
执行期间请保持页面开启。调试依赖与 Data Sync 相同的 builder 配置,并实时返回日志。
|
||||||
|
</p>
|
||||||
|
{errorMessage ? <p className="text-rose-400 text-xs">{errorMessage}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{runMeta ? (
|
||||||
|
<section className="space-y-2 rounded-lg bg-background-secondary/70 px-4 py-3 text-xs">
|
||||||
|
<p className="text-text text-sm font-semibold">最近一次任务</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<DetailRow label="文件">{runMeta.filename}</DetailRow>
|
||||||
|
<DetailRow label="大小">{formatBytes(runMeta.size)}</DetailRow>
|
||||||
|
<DetailRow label="Storage Key">{runMeta.storageKey}</DetailRow>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-lg bg-fill/10 px-3 py-2 text-[11px] leading-5 text-text-tertiary">
|
||||||
|
<p>⚠️ 调试以安全模式运行:</p>
|
||||||
|
<ul className="mt-1 list-disc pl-4">
|
||||||
|
<li>不写入照片资产数据库记录</li>
|
||||||
|
<li>不在存储中保留任何调试产物</li>
|
||||||
|
<li>所有日志均实时输出,供排查使用</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</LinearBorderPanel>
|
||||||
|
|
||||||
|
<LinearBorderPanel className="bg-background-tertiary/70 relative flex min-h-[420px] flex-col rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text text-base font-semibold">实时日志</p>
|
||||||
|
<p className="text-text-tertiary text-xs">最新 {logEntries.length} 条消息</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-text-tertiary text-xs">来源:Builder + Data Sync Relay</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={logViewportRef}
|
||||||
|
className="border-border/20 bg-background-secondary/40 mt-4 flex-1 overflow-y-auto rounded-xl border p-3"
|
||||||
|
>
|
||||||
|
{logEntries.length === 0 ? (
|
||||||
|
<div className="text-text-tertiary flex h-full items-center justify-center text-sm">
|
||||||
|
{isRunning ? '正在初始化调试环境...' : '尚无日志'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
{logEntries.map((entry) => (
|
||||||
|
<li key={entry.id} className="flex items-start gap-3">
|
||||||
|
<span className="text-text-tertiary w-14 shrink-0 font-mono">{formatTime(entry.timestamp)}</span>
|
||||||
|
<LogPill entry={entry} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LinearBorderPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinearBorderPanel className="bg-background-tertiary/70 rounded-xl p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-text text-base font-semibold">调试输出</p>
|
||||||
|
<p className="text-text-tertiary text-xs">展示 Builder 返回的 manifest 摘要</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleCopyManifest} disabled={!manifestJson}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
复制 manifest
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<SummaryTile label="Result Type" value={result.resultType.toUpperCase()} />
|
||||||
|
<SummaryTile label="Storage Key" value={result.storageKey} isMono />
|
||||||
|
<SummaryTile label="缩略图 URL" value={result.thumbnailUrl || '未生成'} isMono />
|
||||||
|
<SummaryTile label="产物已清理" value={result.filesDeleted ? 'Yes' : 'No'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manifestJson ? (
|
||||||
|
<pre className="border-border/30 bg-background-secondary/70 relative max-h-[360px] overflow-auto rounded-xl border p-4 text-xs text-text">
|
||||||
|
{manifestJson}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<p className="text-text-tertiary text-sm">当前任务未生成 manifest 数据。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-tertiary mt-4 text-sm">运行调试后,这里会显示 manifest 内容与概要。</div>
|
||||||
|
)}
|
||||||
|
</LinearBorderPanel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTile({ label, value, isMono }: { label: string; value: string; isMono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="border-border/30 bg-background-secondary/60 rounded-lg border px-3 py-2 text-xs">
|
||||||
|
<p className="text-text-tertiary uppercase tracking-wide">{label}</p>
|
||||||
|
<p className={clsxm('text-text mt-1 text-sm wrap-break-word', isMono && 'font-mono text-[11px]')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: RunStatus }) {
|
||||||
|
const config = STATUS_LABEL[status]
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<span className={clsxm('relative inline-flex h-2.5 w-2.5 items-center justify-center', config.className)}>
|
||||||
|
<span className="bg-current inline-flex h-1.5 w-1.5 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span className={config.className}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-text-tertiary flex gap-2">
|
||||||
|
<span className="min-w-[72px] text-right text-[11px] uppercase tracking-wide">{label}</span>
|
||||||
|
<span className="text-text text-xs font-mono">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogPill({ entry }: { entry: DebugLogEntry }) {
|
||||||
|
if (entry.type === 'log') {
|
||||||
|
return (
|
||||||
|
<div className={clsxm('min-w-0 flex-1 rounded-lg border px-3 py-2 text-xs', LEVEL_THEME[entry.level])}>
|
||||||
|
<p className="font-semibold uppercase tracking-wide text-[10px]">{entry.level}</p>
|
||||||
|
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone =
|
||||||
|
entry.type === 'error'
|
||||||
|
? 'border border-rose-500/40 bg-rose-500/10 text-rose-100'
|
||||||
|
: entry.type === 'start'
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'bg-emerald-500/10 text-emerald-100'
|
||||||
|
const label = entry.type === 'start' ? 'START' : entry.type === 'complete' ? 'COMPLETE' : 'ERROR'
|
||||||
|
return (
|
||||||
|
<div className={clsxm('min-w-0 flex-1 rounded-lg px-3 py-2 text-xs', tone)}>
|
||||||
|
<p className="font-semibold uppercase tracking-wide text-[10px]">{label}</p>
|
||||||
|
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogEntry(event: BuilderDebugProgressEvent): DebugLogEntry | null {
|
||||||
|
const id = nanoid()
|
||||||
|
const timestamp = Date.now()
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'start': {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'start',
|
||||||
|
message: `上传 ${event.payload.filename},准备执行 Builder`,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'complete': {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'complete',
|
||||||
|
message: `构建完成 · 结果 ${event.payload.resultType}`,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'error',
|
||||||
|
message: event.payload.message,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'log': {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'log',
|
||||||
|
level: event.payload.level,
|
||||||
|
message: event.payload.message,
|
||||||
|
timestamp: Date.parse(event.payload.timestamp) || timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,48 @@
|
|||||||
import { Button, ScrollArea } from '@afilmory/ui'
|
import { ScrollArea } from '@afilmory/ui'
|
||||||
import { Spring } from '@afilmory/utils'
|
|
||||||
import { m } from 'motion/react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Navigate, NavLink, Outlet } from 'react-router'
|
import { Navigate, NavLink, Outlet } from 'react-router'
|
||||||
|
|
||||||
import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth'
|
import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth'
|
||||||
import { usePageRedirect } from '~/hooks/usePageRedirect'
|
import { SuperAdminUserMenu } from '~/components/common/SuperAdminUserMenu'
|
||||||
|
|
||||||
const navigationTabs = [{ label: 'System Settings', path: '/superadmin/settings' }] as const
|
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const { logout } = usePageRedirect()
|
|
||||||
const user = useAuthUserValue()
|
const user = useAuthUserValue()
|
||||||
const isSuperAdmin = useIsSuperAdmin()
|
const isSuperAdmin = useIsSuperAdmin()
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
const navItems = [
|
||||||
|
{ to: '/superadmin/settings', label: '系统设置', end: true },
|
||||||
|
{ to: '/superadmin/debug', label: 'Builder 调试', end: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
if (user && !isSuperAdmin) {
|
if (user && !isSuperAdmin) {
|
||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
if (isLoggingOut) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoggingOut(true)
|
|
||||||
try {
|
|
||||||
await logout()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout failed:', error)
|
|
||||||
setIsLoggingOut(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<nav className="border-border/50 bg-background-tertiary shrink-0 border-b px-6 py-3">
|
<nav className="bg-background-tertiary relative shrink-0 px-6 py-3">
|
||||||
<div className="flex items-center gap-6">
|
{/* Bottom border with gradient */}
|
||||||
|
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
{/* Logo/Brand */}
|
||||||
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
|
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center gap-1">
|
<div className="flex flex-1 items-center gap-1">
|
||||||
{navigationTabs.map((tab) => (
|
{navItems.map((tab) => (
|
||||||
<NavLink key={tab.path} to={tab.path} end={tab.path === '/superadmin/settings'}>
|
<NavLink key={tab.to} to={tab.to} end={tab.end}>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<m.div
|
<div
|
||||||
className="relative overflow-hidden rounded-md px-3 py-1.5"
|
className="relative overflow-hidden rounded-md shape-squircle px-3 py-1.5 group data-[state=active]:bg-accent/80 data-[state=active]:text-white"
|
||||||
initial={false}
|
data-state={isActive ? 'active' : 'inactive'}
|
||||||
animate={{
|
|
||||||
backgroundColor: isActive
|
|
||||||
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
|
|
||||||
: 'transparent',
|
|
||||||
}}
|
|
||||||
whileHover={{
|
|
||||||
backgroundColor: isActive
|
|
||||||
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
|
|
||||||
: 'color-mix(in srgb, var(--color-fill) 60%, transparent)',
|
|
||||||
}}
|
|
||||||
transition={Spring.presets.snappy}
|
|
||||||
>
|
>
|
||||||
<span
|
<span className="relative z-10 text-[13px] font-medium">{tab.label}</span>
|
||||||
className="relative z-10 text-[13px] font-medium transition-colors"
|
</div>
|
||||||
style={{
|
|
||||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</span>
|
|
||||||
</m.div>
|
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Right side - User Menu */}
|
||||||
{user && (
|
{user && <SuperAdminUserMenu user={user} />}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-text text-[13px] font-medium">{user.name || user.email}</div>
|
|
||||||
<div className="text-text-tertiary text-[11px] capitalize">{user.role}</div>
|
|
||||||
</div>
|
|
||||||
{user.image && <img src={user.image} alt={user.name || user.email} className="size-7 rounded-full" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleLogout}
|
|
||||||
disabled={isLoggingOut}
|
|
||||||
isLoading={isLoggingOut}
|
|
||||||
loadingText="Logging out..."
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: afilmory_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: afilmory
|
||||||
|
POSTGRES_PASSWORD: afilmory
|
||||||
|
POSTGRES_DB: afilmory
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: afilmory_redis
|
||||||
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
core:
|
||||||
|
container_name: afilmory_core
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.core
|
||||||
|
target: runner
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOSTNAME: '0.0.0.0'
|
||||||
|
PORT: '1841'
|
||||||
|
DATABASE_URL: 'postgresql://afilmory:afilmory@db:5432/afilmory'
|
||||||
|
REDIS_URL: 'redis://redis:6379/0'
|
||||||
|
CONFIG_ENCRYPTION_KEY: '756256a24394342622db58ee05ee61a64a3ae14c22d4fe665b753cba0fa6333e'
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- '1841:1841'
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
@@ -10,7 +10,7 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
|
|||||||
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
|
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
|
||||||
- Extracts a slug via `tenant-host.utils.ts`.
|
- Extracts a slug via `tenant-host.utils.ts`.
|
||||||
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
|
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
|
||||||
- Always stores the original `requestedSlug` (even when placeholder), echoes headers `x-tenant-id` and `x-tenant-slug` (effective slug).
|
- Always stores the original `requestedSlug` (even when placeholder) so downstream services know which workspace was requested.
|
||||||
|
|
||||||
## Auth Provider
|
## Auth Provider
|
||||||
|
|
||||||
@@ -76,4 +76,3 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
|
|||||||
- Only a single `tenant.slug` crosses the API boundary; there are no ambiguous fields.
|
- Only a single `tenant.slug` crosses the API boundary; there are no ambiguous fields.
|
||||||
- Placeholder detection is a boolean (`isPlaceholder`).
|
- Placeholder detection is a boolean (`isPlaceholder`).
|
||||||
- Better Auth instances survive OAuth handshakes regardless of tenant provisioning state.
|
- Better Auth instances survive OAuth handshakes regardless of tenant provisioning state.
|
||||||
- Headers `x-tenant-id` / `x-tenant-slug` always mirror the effective slug, so backend services and the dashboard remain consistent.
|
|
||||||
|
|||||||
@@ -658,7 +658,7 @@ export class AfilmoryBuilder {
|
|||||||
addReference(ref)
|
addReference(ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.getUserSettings().repo.enable && !hasPluginWithName('afilmory:github-repo-sync')) {
|
if (this.getUserSettings().repo?.enable && !hasPluginWithName('afilmory:github-repo-sync')) {
|
||||||
addReference(() => import('@afilmory/builder/plugins/github-repo-sync.js'))
|
addReference(() => import('@afilmory/builder/plugins/github-repo-sync.js'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +681,7 @@ export class AfilmoryBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getStorageConfig(): StorageConfig {
|
getStorageConfig(): StorageConfig {
|
||||||
const {storage} = this.getUserSettings()
|
const { storage } = this.getUserSettings()
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
throw new Error('Storage configuration is missing. 请配置 system/user storage 设置。')
|
throw new Error('Storage configuration is missing. 请配置 system/user storage 设置。')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,4 +177,20 @@ export type EagleConfig = {
|
|||||||
omitTagNamesInMetadata?: string[]
|
omitTagNamesInMetadata?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig
|
/**
|
||||||
|
* Additional storage configuration surface that downstream projects can extend via
|
||||||
|
* module augmentation, e.g.
|
||||||
|
*
|
||||||
|
* declare module '@afilmory/builder/storage/interfaces.js' {
|
||||||
|
* interface CustomStorageConfig {
|
||||||
|
* provider: 'my-provider'
|
||||||
|
* foo?: string
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface CustomStorageConfig {
|
||||||
|
provider: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig | CustomStorageConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user