feat: implement tenant missing page and enhance static web response handling

- Added a new tenant missing page to provide user feedback when a tenant is unavailable.
- Updated StaticWebController to check for tenant context and render the tenant missing page when necessary.
- Refactored StaticAssetService to generalize HTML response creation.
- Introduced new methods for handling tenant missing scenarios and improved response management in StaticWebService.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-11 20:20:01 +08:00
parent ee938d1f67
commit 6b1f124af6
12 changed files with 232 additions and 105 deletions

View File

@@ -307,8 +307,8 @@ export abstract class StaticAssetService {
}
private async createResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise<Response> {
if (file.relativePath === 'index.html') {
return await this.createIndexResponse(file, headOnly)
if (this.isHtml(file.relativePath)) {
return await this.createHtmlResponse(file, headOnly)
}
const mimeType = lookupMimeType(file.absolutePath) || 'application/octet-stream'
@@ -328,7 +328,7 @@ export abstract class StaticAssetService {
return new Response(body, { headers, status: 200 })
}
private async createIndexResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise<Response> {
private async createHtmlResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise<Response> {
const html = await readFile(file.absolutePath, 'utf-8')
const transformed = await this.transformIndexHtml(html, file)
const headers = new Headers()

View File

@@ -1,11 +1,14 @@
import { ContextParam, Controller, Get, Param } from '@afilmory/framework'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
import type { Context } from 'hono'
import type { StaticAssetService } from './static-asset.service'
import { STATIC_DASHBOARD_BASENAME, StaticDashboardService } from './static-dashboard.service'
import { StaticWebService } from './static-web.service'
const TENANT_MISSING_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-missing.html`
@Controller({ bypassGlobalPrefix: true })
export class StaticWebController {
constructor(
@@ -24,12 +27,26 @@ export class StaticWebController {
@Get(`/explory`)
@SkipTenantGuard()
async getStaticWebIndex(@ContextParam() context: Context) {
return await this.serve(context, this.staticWebService, false)
if (this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
}
const response = await this.serve(context, this.staticWebService, false)
if (response.status === 404) {
return await this.renderTenantMissingPage()
}
return response
}
@Get(`/photos/:photoId`)
async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
if (this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
}
const response = await this.serve(context, this.staticWebService, false)
if (response.status === 404) {
return await this.renderTenantMissingPage()
}
return await this.staticWebService.decoratePhotoPageResponse(context, photoId, response)
}
@@ -37,7 +54,15 @@ export class StaticWebController {
@Get(`${STATIC_DASHBOARD_BASENAME}`)
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
return await this.serve(context, this.staticDashboardService, false)
const isHtmlRoute = this.isHtmlRoute(context.req.path)
if (isHtmlRoute && this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
}
const response = await this.serve(context, this.staticDashboardService, false)
if (isHtmlRoute && response.status === 404) {
return await this.renderTenantMissingPage()
}
return response
}
@SkipTenantGuard()
@@ -97,4 +122,48 @@ export class StaticWebController {
private isLegacyDashboardPath(pathname: string): boolean {
return pathname === '/static/dashboard' || pathname.startsWith('/static/dashboard/')
}
private isHtmlRoute(pathname: string): boolean {
if (!pathname) {
return true
}
const normalized = pathname.split('?')[0]?.trim() ?? ''
if (!normalized || normalized === '/' || normalized.endsWith('/')) {
return true
}
const lastSegment = normalized.split('/').pop()
if (!lastSegment) {
return true
}
if (lastSegment.endsWith('.html')) {
return true
}
return !lastSegment.includes('.')
}
private shouldRenderTenantMissingPage(): boolean {
const tenantContext = getTenantContext()
return !tenantContext || isPlaceholderTenantContext(tenantContext)
}
private async renderTenantMissingPage(): Promise<Response> {
const response = await this.staticDashboardService.handleRequest(TENANT_MISSING_ENTRY_PATH, false)
if (response) {
return this.cloneResponseWithStatus(response, 404)
}
return new Response('Workspace unavailable', { status: 404 })
}
private cloneResponseWithStatus(response: Response, status: number): Response {
const headers = new Headers(response.headers)
return new Response(response.body, {
status,
headers,
})
}
}

View File

@@ -80,14 +80,14 @@ export class StaticWebService extends StaticAssetService {
const headers = new Headers(response.headers)
const photo = await this.findPhoto(photoId)
if (!photo) {
return this.createHtmlResponse(html, headers, 404)
return this.createManualHtmlResponse(html, headers, 404)
}
const siteConfig = await this.siteSettingService.getSiteConfig()
const siteTitle = siteConfig.title?.trim() || siteConfig.name || 'Photo Gallery'
const origin = this.resolveRequestOrigin(context)
if (!origin) {
return this.createHtmlResponse(html, headers, response.status)
return this.createManualHtmlResponse(html, headers, response.status)
}
try {
@@ -98,10 +98,10 @@ export class StaticWebService extends StaticAssetService {
this.insertTwitterTags(document, photo, origin, siteTitle)
const serialized = document.documentElement.outerHTML
return this.createHtmlResponse(serialized, headers, 200)
return this.createManualHtmlResponse(serialized, headers, 200)
} catch (error) {
this.logger.error('Failed to inject Open Graph tags for photo page', { error })
return this.createHtmlResponse(html, headers, response.status)
return this.createManualHtmlResponse(html, headers, response.status)
}
}
@@ -252,12 +252,9 @@ export class StaticWebService extends StaticAssetService {
}
}
private createHtmlResponse(html: string, baseHeaders: Headers, status: number): Response {
private createManualHtmlResponse(html: string, baseHeaders: Headers, status: number): Response {
const headers = new Headers(baseHeaders)
headers.set('content-length', Buffer.byteLength(html, 'utf8').toString())
return new Response(html, {
status,
headers,
})
return new Response(html, { status, headers })
}
}

View File

@@ -0,0 +1,13 @@
import '../styles/index.css'
import { createRoot } from 'react-dom/client'
import { TenantMissingStandalone } from '../modules/welcome/components/TenantMissingStandalone'
const root = document.querySelector('#root')
if (!root) {
throw new Error('Root element not found for tenant missing entry.')
}
createRoot(root).render(<TenantMissingStandalone />)

View File

@@ -0,0 +1,99 @@
import { Button } from '@afilmory/ui'
import { useMemo } from 'react'
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
import { LinearBorderContainer } from './LinearBorderContainer'
const getCurrentHostname = () => {
if (typeof window === 'undefined') {
return null
}
try {
return window.location.hostname
} catch {
return null
}
}
const buildRegistrationUrl = () => {
if (typeof window === 'undefined') {
return '/platform/welcome'
}
try {
const { protocol, hostname, port } = window.location
const baseDomain = resolveBaseDomain(hostname) || hostname
const normalizedPort = port ? `:${port}` : ''
return `${protocol}//${baseDomain}${normalizedPort}/platform/welcome`
} catch {
return '/platform/welcome'
}
}
const buildHomeUrl = () => {
if (typeof window === 'undefined') {
return '/'
}
try {
const { protocol, hostname, port } = window.location
const normalizedPort = port ? `:${port}` : ''
return `${protocol}//${hostname}${normalizedPort}`
} catch {
return '/'
}
}
export const TenantMissingStandalone = () => {
const hostname = useMemo(() => getCurrentHostname(), [])
const registrationUrl = useMemo(() => buildRegistrationUrl(), [])
const homeUrl = useMemo(() => buildHomeUrl(), [])
return (
<div className="relative flex min-h-dvh flex-1 flex-col bg-background text-text">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -inset-32 bg-linear-to-br from-accent/20 via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</div>
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">404</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl"></h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
访访
使 Afilmory
</p>
{hostname && (
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
<span className="text-text font-medium">{hostname}</span>
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="primary"
className="glassmorphic-btn flex-1"
onClick={() => (window.location.href = registrationUrl)}
>
</Button>
<Button variant="ghost" className="flex-1" onClick={() => (window.location.href = homeUrl)}>
</Button>
</div>
</div>
</div>
</div>
</LinearBorderContainer>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="description" content="找不到对应的空间,请注册后继续使用。" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Afilmory - 空间不可用</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap"
rel="stylesheet"
referrerpolicy="no-referrer"
/>
<style>
html {
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/tenant-missing.tsx"></script>
</body>
</html>

View File

@@ -1,6 +1,7 @@
import 'dotenv/config'
import { fileURLToPath, resolve } from 'node:url'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import reactRefresh from '@vitejs/plugin-react'
@@ -18,16 +19,17 @@ const API_TARGET = process.env.CORE_API_URL || 'http://localhost:3000'
export default defineConfig({
plugins: [
codeInspectorPlugin({
bundler: 'vite',
hotKeys: ['altKey'],
}),
reactRefresh(),
tsconfigPaths(),
checker({
typescript: true,
enableBuild: true,
}),
codeInspectorPlugin({
bundler: 'vite',
hotKeys: ['altKey'],
}),
tailwindcss(),
routeBuilderPlugin({
pagePattern: `${resolve(ROOT, './src/pages')}/**/*.tsx`,
@@ -70,4 +72,12 @@ export default defineConfig({
},
},
},
build: {
rollupOptions: {
input: {
main: resolve(ROOT, 'index.html'),
'tenant-missing': resolve(ROOT, 'tenant-missing.html'),
},
},
},
})

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WS Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
{
"name": "core-demo",
"type": "module",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.18.0",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"preview": "vite preview --port 5174"
},
"dependencies": {
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"typescript": "5.9.3",
"vite": "7.1.12",
"vite-tsconfig-paths": "5.1.4"
}
}

View File

@@ -1,10 +0,0 @@
import * as React from 'react'
import { createRoot } from 'react-dom/client'
const container = document.querySelector('#root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<div />
</React.StrictMode>,
)

View File

@@ -1,20 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": []
},
"include": ["src", "vite.config.ts"],
"references": []
}

View File

@@ -1,21 +0,0 @@
import { env } from 'node:process'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
const API_PORT = Number(env.CORE_PORT ?? 3000)
const API_HOST = env.CORE_HOST ?? '0.0.0.0'
export default defineConfig({
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: `http://${API_HOST}:${API_PORT}`,
changeOrigin: true,
},
},
},
plugins: [tsconfigPaths()],
})