mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
13
be/apps/dashboard/src/entries/tenant-missing.tsx
Normal file
13
be/apps/dashboard/src/entries/tenant-missing.tsx
Normal 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 />)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
25
be/apps/dashboard/tenant-missing.html
Normal file
25
be/apps/dashboard/tenant-missing.html
Normal 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>
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
Reference in New Issue
Block a user