Skip to content

Commit efa5ade

Browse files
committed
feat(static-assets): introduce StaticAssetHostService and enhance static asset handling and i18n
- Added StaticAssetHostService to manage static asset host resolution with caching. - Updated StaticAssetService and StaticDashboardService to utilize the new static asset host resolver. - Enhanced StaticWebController to pass request host information for improved asset handling. - Refactored static asset interfaces to support new functionality. - Integrated CORS headers and cache policies for better asset management. Signed-off-by: Innei <tukon479@gmail.com>
1 parent c94a601 commit efa5ade

32 files changed

+2675
-636
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createLogger } from '@afilmory/framework'
2+
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
3+
import { injectable } from 'tsyringe'
4+
5+
@injectable()
6+
export class StaticAssetHostService {
7+
private readonly logger = createLogger('StaticAssetHostService')
8+
private readonly cache = new Map<string, string | null>()
9+
10+
constructor(private readonly systemSettingService: SystemSettingService) {}
11+
12+
async getStaticAssetHost(requestHost?: string | null): Promise<string | null> {
13+
const cacheKey = this.buildCacheKey(requestHost)
14+
if (this.cache.has(cacheKey)) {
15+
return this.cache.get(cacheKey) ?? null
16+
}
17+
18+
const resolved = await this.resolveStaticAssetHost(requestHost)
19+
this.cache.set(cacheKey, resolved ?? null)
20+
return resolved ?? null
21+
}
22+
23+
private buildCacheKey(requestHost?: string | null): string {
24+
if (!requestHost) {
25+
return '__default__'
26+
}
27+
return requestHost.trim().toLowerCase()
28+
}
29+
30+
private async resolveStaticAssetHost(requestHost?: string | null): Promise<string | null> {
31+
try {
32+
const settings = await this.systemSettingService.getSettings()
33+
const baseDomain = settings.baseDomain?.trim().toLowerCase()
34+
if (!baseDomain) {
35+
return null
36+
}
37+
38+
if (this.isLocalDomain(baseDomain)) {
39+
const port = this.extractPort(requestHost)
40+
return port ? `//static.${baseDomain}:${port}` : `//static.${baseDomain}`
41+
}
42+
43+
return `//static.${baseDomain}`
44+
} catch (error) {
45+
this.logger.warn('Failed to load system settings for static asset host', error)
46+
return null
47+
}
48+
}
49+
50+
private isLocalDomain(baseDomain: string): boolean {
51+
if (baseDomain === 'localhost') {
52+
return true
53+
}
54+
55+
if (baseDomain.endsWith('.localhost')) {
56+
return true
57+
}
58+
59+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(baseDomain)) {
60+
return true
61+
}
62+
63+
return false
64+
}
65+
66+
private extractPort(requestHost?: string | null): string | null {
67+
if (!requestHost) {
68+
return null
69+
}
70+
71+
const host = requestHost.trim()
72+
if (!host) {
73+
return null
74+
}
75+
76+
if (host.startsWith('[')) {
77+
const closingIndex = host.indexOf(']')
78+
if (closingIndex !== -1 && closingIndex + 1 < host.length && host[closingIndex + 1] === ':') {
79+
return host.slice(closingIndex + 2)
80+
}
81+
return null
82+
}
83+
84+
const segments = host.split(':')
85+
if (segments.length <= 1) {
86+
return null
87+
}
88+
89+
return segments.at(-1)
90+
}
91+
}

be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export interface StaticAssetServiceOptions {
3131
loggerName?: string
3232
rewriteAssetReferences?: boolean
3333
assetLinkRels?: Iterable<string>
34+
staticAssetHostResolver?: (requestHost?: string | null) => Promise<string | null>
35+
}
36+
37+
export interface StaticAssetRequestOptions {
38+
requestHost?: string | null
3439
}
3540

3641
export interface ResolvedStaticAsset {
@@ -42,18 +47,25 @@ export interface ResolvedStaticAsset {
4247
export abstract class StaticAssetService {
4348
protected readonly logger: PrettyLogger
4449
private readonly assetLinkRels: ReadonlySet<string>
50+
private readonly staticAssetHostResolver?: (requestHost?: string | null) => Promise<string | null>
4551

4652
private staticRoot: string | null | undefined
53+
private staticAssetHosts = new Map<string, string | null>()
4754
private warnedMissingRoot = false
4855

4956
protected constructor(private readonly options: StaticAssetServiceOptions) {
5057
this.logger = createLogger(options.loggerName ?? this.constructor.name)
5158
this.assetLinkRels = new Set(
5259
options.assetLinkRels ? Array.from(options.assetLinkRels, (rel) => rel.toLowerCase()) : DEFAULT_ASSET_LINK_RELS,
5360
)
61+
this.staticAssetHostResolver = options.staticAssetHostResolver
5462
}
5563

56-
async handleRequest(fullPath: string, headOnly: boolean): Promise<Response | null> {
64+
async handleRequest(
65+
fullPath: string,
66+
headOnly: boolean,
67+
options?: StaticAssetRequestOptions,
68+
): Promise<Response | null> {
5769
const staticRoot = await this.resolveStaticRoot()
5870
if (!staticRoot) {
5971
return null
@@ -65,7 +77,7 @@ export abstract class StaticAssetService {
6577
return null
6678
}
6779

68-
return await this.createResponse(target, headOnly)
80+
return await this.createResponse(target, headOnly, options)
6981
}
7082

7183
protected get routeSegment(): string {
@@ -78,10 +90,10 @@ export abstract class StaticAssetService {
7890

7991
protected async decorateDocument(_document: StaticAssetDocument, _file: ResolvedStaticAsset): Promise<void> {}
8092

81-
protected rewriteStaticAssetReferences(document: StaticAssetDocument): void {
93+
protected rewriteStaticAssetReferences(document: StaticAssetDocument, staticAssetHost: string | null): void {
8294
const prefixAttr = (element: Element, attr: string) => {
8395
const current = element.getAttribute(attr)
84-
const next = this.prefixStaticAssetPath(current)
96+
const next = this.applyStaticAssetPrefixes(current, staticAssetHost)
8597
if (next !== null && next !== current) {
8698
element.setAttribute(attr, next)
8799
}
@@ -106,7 +118,7 @@ export abstract class StaticAssetService {
106118

107119
document.querySelectorAll('img[srcset], source[srcset]').forEach((element) => {
108120
const current = element.getAttribute('srcset')
109-
const next = this.prefixSrcset(current)
121+
const next = this.prefixSrcset(current, staticAssetHost)
110122
if (next !== null && next !== current) {
111123
element.setAttribute('srcset', next)
112124
}
@@ -148,7 +160,12 @@ export abstract class StaticAssetService {
148160
return value === trimmed ? prefixed : value.replace(trimmed, prefixed)
149161
}
150162

151-
private prefixSrcset(value: string | null): string | null {
163+
private applyStaticAssetPrefixes(value: string | null, staticAssetHost: string | null): string | null {
164+
const prefixed = this.prefixStaticAssetPath(value)
165+
return this.prefixStaticAssetHost(prefixed, staticAssetHost)
166+
}
167+
168+
private prefixSrcset(value: string | null, staticAssetHost: string | null): string | null {
152169
if (!value) {
153170
return value
154171
}
@@ -160,13 +177,27 @@ export abstract class StaticAssetService {
160177
}
161178

162179
const [url, ...rest] = trimmed.split(/\s+/)
163-
const prefixed = this.prefixStaticAssetPath(url) ?? url
180+
const prefixed = this.applyStaticAssetPrefixes(url, staticAssetHost) ?? url
164181
return [prefixed, ...rest].join(' ').trim()
165182
})
166183

167184
return parts.join(', ')
168185
}
169186

187+
private prefixStaticAssetHost(value: string | null, staticAssetHost: string | null): string | null {
188+
if (!value || !staticAssetHost) {
189+
return value
190+
}
191+
192+
const trimmed = value.trim()
193+
if (!trimmed.startsWith(this.routeSegment)) {
194+
return value
195+
}
196+
197+
const rewrote = `${staticAssetHost}${trimmed}`
198+
return value === trimmed ? rewrote : value.replace(trimmed, rewrote)
199+
}
200+
170201
private async resolveStaticRoot(): Promise<string | null> {
171202
if (this.staticRoot !== undefined) {
172203
return this.staticRoot
@@ -307,9 +338,13 @@ export abstract class StaticAssetService {
307338
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath)
308339
}
309340

310-
private async createResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise<Response> {
341+
private async createResponse(
342+
file: ResolvedStaticAsset,
343+
headOnly: boolean,
344+
options?: StaticAssetRequestOptions,
345+
): Promise<Response> {
311346
if (this.isHtml(file.relativePath)) {
312-
return await this.createHtmlResponse(file, headOnly)
347+
return await this.createHtmlResponse(file, headOnly, options)
313348
}
314349

315350
const mimeType = lookupMimeType(file.absolutePath) || 'application/octet-stream'
@@ -319,6 +354,7 @@ export abstract class StaticAssetService {
319354
headers.set('last-modified', file.stats.mtime.toUTCString())
320355

321356
this.applyCacheHeaders(headers, file.relativePath)
357+
this.applyCorsHeaders(headers)
322358

323359
if (headOnly) {
324360
return new Response(null, { headers, status: 200 })
@@ -329,14 +365,19 @@ export abstract class StaticAssetService {
329365
return new Response(body, { headers, status: 200 })
330366
}
331367

332-
private async createHtmlResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise<Response> {
368+
private async createHtmlResponse(
369+
file: ResolvedStaticAsset,
370+
headOnly: boolean,
371+
options?: StaticAssetRequestOptions,
372+
): Promise<Response> {
333373
const html = await readFile(file.absolutePath, 'utf-8')
334-
const transformed = await this.transformIndexHtml(html, file)
374+
const transformed = await this.transformIndexHtml(html, file, options)
335375
const headers = new Headers()
336376
headers.set('content-type', 'text/html; charset=utf-8')
337377
headers.set('content-length', `${Buffer.byteLength(transformed, 'utf-8')}`)
338378
headers.set('last-modified', file.stats.mtime.toUTCString())
339379
this.applyCacheHeaders(headers, file.relativePath)
380+
this.applyCorsHeaders(headers)
340381

341382
if (headOnly) {
342383
return new Response(null, { headers, status: 200 })
@@ -345,12 +386,17 @@ export abstract class StaticAssetService {
345386
return new Response(transformed, { headers, status: 200 })
346387
}
347388

348-
private async transformIndexHtml(html: string, file: ResolvedStaticAsset): Promise<string> {
389+
private async transformIndexHtml(
390+
html: string,
391+
file: ResolvedStaticAsset,
392+
options?: StaticAssetRequestOptions,
393+
): Promise<string> {
349394
try {
350395
const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument
351396
await this.decorateDocument(document, file)
352397
if (this.shouldRewriteAssetReferences(file)) {
353-
this.rewriteStaticAssetReferences(document)
398+
const staticAssetHost = await this.getStaticAssetHost(options?.requestHost)
399+
this.rewriteStaticAssetReferences(document, staticAssetHost)
354400
}
355401
return document.documentElement.outerHTML
356402
} catch (error) {
@@ -359,6 +405,35 @@ export abstract class StaticAssetService {
359405
}
360406
}
361407

408+
private async getStaticAssetHost(requestHost?: string | null): Promise<string | null> {
409+
if (!this.staticAssetHostResolver) {
410+
return null
411+
}
412+
413+
const cacheKey = this.buildStaticAssetHostCacheKey(requestHost)
414+
if (this.staticAssetHosts.has(cacheKey)) {
415+
return this.staticAssetHosts.get(cacheKey) ?? null
416+
}
417+
418+
try {
419+
const resolved = await this.staticAssetHostResolver(requestHost)
420+
this.staticAssetHosts.set(cacheKey, resolved ?? null)
421+
return resolved ?? null
422+
} catch (error) {
423+
this.logger.warn('Failed to resolve static asset host', error)
424+
this.staticAssetHosts.set(cacheKey, null)
425+
}
426+
427+
return null
428+
}
429+
430+
private buildStaticAssetHostCacheKey(requestHost?: string | null): string {
431+
if (!requestHost) {
432+
return '__default__'
433+
}
434+
return requestHost.trim().toLowerCase()
435+
}
436+
362437
private shouldTreatAsImmutable(relativePath: string): boolean {
363438
if (this.isHtml(relativePath)) {
364439
return false
@@ -374,6 +449,12 @@ export abstract class StaticAssetService {
374449
headers.set('surrogate-control', policy.cdn)
375450
}
376451

452+
private applyCorsHeaders(headers: Headers): void {
453+
headers.set('access-control-allow-origin', '*')
454+
headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS')
455+
headers.set('access-control-allow-headers', 'content-type')
456+
}
457+
377458
private resolveCachePolicy(relativePath: string): { browser: string; cdn: string } {
378459
if (this.isHtml(relativePath)) {
379460
return {

be/apps/core/src/modules/infrastructure/static-web/static-dashboard.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { injectable } from 'tsyringe'
55

66
import type { StaticAssetDocument } from './static-asset.service'
77
import { StaticAssetService } from './static-asset.service'
8+
import { StaticAssetHostService } from './static-asset-host.service'
89

910
const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
1011

@@ -36,11 +37,12 @@ const STATIC_DASHBOARD_ROOT_CANDIDATES = Array.from(
3637

3738
@injectable()
3839
export class StaticDashboardService extends StaticAssetService {
39-
constructor() {
40+
constructor(private readonly staticAssetHostService: StaticAssetHostService) {
4041
super({
4142
routeSegment: STATIC_DASHBOARD_ROUTE_SEGMENT,
4243
rootCandidates: STATIC_DASHBOARD_ROOT_CANDIDATES,
4344
loggerName: 'StaticDashboardService',
45+
staticAssetHostResolver: (requestHost) => staticAssetHostService.getStaticAssetHost(requestHost),
4446
})
4547
}
4648

be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export class StaticWebController {
101101
private async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise<Response> {
102102
const pathname = context.req.path
103103
const normalizedPath = this.normalizeRequestPath(pathname, service)
104-
const response = await service.handleRequest(normalizedPath, headOnly)
104+
const response = await service.handleRequest(normalizedPath, headOnly, {
105+
requestHost: this.resolveRequestHost(context),
106+
})
105107
if (response) {
106108
return response
107109
}
@@ -191,6 +193,25 @@ export class StaticWebController {
191193
return trimmed
192194
}
193195

196+
private resolveRequestHost(context: Context): string | null {
197+
const forwardedHost = context.req.header('x-forwarded-host')?.trim()
198+
if (forwardedHost) {
199+
return forwardedHost
200+
}
201+
202+
const host = context.req.header('host')?.trim()
203+
if (host) {
204+
return host
205+
}
206+
207+
try {
208+
const url = new URL(context.req.url)
209+
return url.host
210+
} catch {
211+
return null
212+
}
213+
}
214+
194215
private isReservedTenant({ root = false }: { root?: boolean } = {}): boolean {
195216
const tenantContext = getTenantContext()
196217
if (!tenantContext) {
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { Module } from '@afilmory/framework'
22
import { SiteSettingModule } from 'core/modules/configuration/site-setting/site-setting.module'
3+
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
34
import { ManifestModule } from 'core/modules/content/manifest/manifest.module'
45

6+
import { StaticAssetHostService } from './static-asset-host.service'
57
import { StaticDashboardService } from './static-dashboard.service'
68
import { StaticWebController } from './static-web.controller'
79
import { StaticWebService } from './static-web.service'
810

911
@Module({
10-
imports: [SiteSettingModule, ManifestModule],
12+
imports: [SiteSettingModule, SystemSettingModule, ManifestModule],
1113
controllers: [StaticWebController],
12-
providers: [StaticWebService, StaticDashboardService],
14+
providers: [StaticAssetHostService, StaticWebService, StaticDashboardService],
1315
})
1416
export class StaticWebModule {}

0 commit comments

Comments
 (0)