Files
Gnuboard7/resources/js/core/template-engine/ErrorPageHandler.ts
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

387 lines
13 KiB
TypeScript

/**
* ErrorPageHandler
*
* 에러 페이지를 처리하는 핸들러 클래스
* template.json의 error_config를 읽어 에러 코드에 맞는 레이아웃을 로드하고 렌더링합니다.
*/
import type { LayoutLoader } from './LayoutLoader';
import type { DataSourceManager, DataSource } from './DataSourceManager';
import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl';
const logger = createLogger('ErrorPageHandler');
/**
* 렌더링 옵션 인터페이스
*/
export interface RenderOptions {
containerId: string;
layoutJson: unknown;
dataContext?: Record<string, unknown>;
translationContext?: {
templateId: string;
locale: string;
};
}
/**
* 렌더링 함수 타입 (의존성 주입용)
*/
export type RenderFunction = (options: RenderOptions) => Promise<void>;
/**
* 에러 설정 인터페이스
*/
export interface ErrorConfig {
layouts: Record<number | string, string>; // { 404: "404", 403: "403", 500: "500" }
}
/**
* ErrorPageHandler 생성자 옵션
*/
export interface ErrorPageHandlerOptions {
templateId: string;
layoutLoader: LayoutLoader;
locale: string;
debug: boolean;
renderFunction: RenderFunction;
dataSourceManager: DataSourceManager;
globalState?: Record<string, unknown>;
}
/**
* 에러 페이지 핸들러 클래스
*/
export class ErrorPageHandler {
private templateId: string;
private layoutLoader: LayoutLoader;
private locale: string;
private debug: boolean;
private renderFunction: RenderFunction;
private dataSourceManager: DataSourceManager;
private globalState: Record<string, unknown>;
private errorConfig: ErrorConfig | null = null;
private configLoaded: boolean = false;
constructor(options: ErrorPageHandlerOptions) {
this.templateId = options.templateId;
this.layoutLoader = options.layoutLoader;
this.locale = options.locale;
this.debug = options.debug;
this.renderFunction = options.renderFunction;
this.dataSourceManager = options.dataSourceManager;
this.globalState = options.globalState || {};
}
/**
* template.json에서 error_config를 로드합니다.
*/
async loadConfig(): Promise<ErrorConfig | null> {
if (this.configLoaded) {
return this.errorConfig;
}
try {
// template.json fetch
const response = await fetch(suffixed(`/api/templates/${this.templateId}/config`, 'json'));
if (!response.ok) {
if (this.debug) {
logger.warn('Failed to load template config:', response.statusText);
}
this.configLoaded = true;
return null;
}
const result = await response.json();
if (!result.success || !result.data) {
if (this.debug) {
logger.warn('Invalid template config response');
}
this.configLoaded = true;
return null;
}
const templateData = result.data;
// error_config 섹션 확인
if (!templateData.error_config || !templateData.error_config.layouts) {
if (this.debug) {
logger.warn('No error_config found in template.json');
}
this.configLoaded = true;
return null;
}
this.errorConfig = templateData.error_config;
this.configLoaded = true;
if (this.debug) {
logger.log('Error config loaded:', this.errorConfig);
}
return this.errorConfig;
} catch (error) {
logger.error('Failed to load error config:', error);
this.configLoaded = true;
return null;
}
}
/**
* 에러 페이지를 렌더링합니다.
*
* @param errorCode 에러 코드 (404, 403, 500 등)
* @param containerId 렌더링할 컨테이너 ID (기본값: 'app')
* @returns 렌더링 성공 여부
*/
async renderError(errorCode: number, containerId: string = 'app'): Promise<boolean> {
try {
// 1. error_config 로드
const config = await this.loadConfig();
if (!config) {
if (this.debug) {
logger.warn('No error config available, cannot render error page');
}
return false;
}
// 2. 에러 코드에 맞는 레이아웃 이름 찾기
const layoutName = config.layouts[errorCode] || config.layouts[String(errorCode)];
if (!layoutName) {
if (this.debug) {
logger.warn(`No layout defined for error code: ${errorCode}`);
}
return false;
}
if (this.debug) {
logger.log(`Loading error layout: ${layoutName} for code: ${errorCode}`);
}
// 3. 레이아웃 로드 (기존 LayoutLoader 활용 - extends 병합 자동 처리)
const layoutData = await this.layoutLoader.loadLayout(this.templateId, layoutName);
if (!layoutData) {
if (this.debug) {
logger.error(`Failed to load error layout: ${layoutName}`);
}
return false;
}
if (this.debug) {
logger.log('Error layout loaded:', layoutData);
}
// 4. data_sources fetch (레이아웃에 정의된 API 데이터 로드)
let fetchedData: Record<string, unknown> = {};
const dataSources: DataSource[] = layoutData.data_sources || [];
if (dataSources.length > 0) {
if (this.debug) {
logger.log('Fetching data sources:', dataSources.map((s: DataSource) => s.id));
}
// blocking 데이터 소스 먼저 fetch
const blockingSources = dataSources.filter(
(s: DataSource) => s.loading_strategy === 'blocking'
);
const progressiveSources = dataSources.filter(
(s: DataSource) => !s.loading_strategy || s.loading_strategy === 'progressive'
);
// blocking + progressive 모두 fetch (에러 페이지에서는 모든 데이터 로드 후 렌더링)
const allSourcesToFetch = [...blockingSources, ...progressiveSources];
if (allSourcesToFetch.length > 0) {
try {
fetchedData = await this.dataSourceManager.fetchDataSources(
allSourcesToFetch,
{}, // 에러 페이지는 라우트 파라미터 없음
new URLSearchParams() // 쿼리 파라미터도 없음
);
if (this.debug) {
logger.log('Data sources fetched:', Object.keys(fetchedData));
}
} catch (fetchError) {
logger.error('Failed to fetch data sources:', fetchError);
// 데이터 fetch 실패해도 에러 페이지는 렌더링 진행
}
}
}
// 5. initGlobal/initLocal 처리 (fetch된 데이터를 _global/_local에 매핑)
const localInit: Record<string, unknown> = {};
if (dataSources.length > 0) {
this.processInitOptions(dataSources, fetchedData, localInit);
if (this.debug) {
logger.log('initOptions processed:', {
globalKeys: Object.keys(this.globalState),
localKeys: Object.keys(localInit),
});
}
}
// 6. 렌더링 (에러 코드 + fetch된 데이터 + 전역 상태 + 로컬 상태)
const dataContext: Record<string, unknown> = {
...fetchedData,
errorCode,
_global: { ...this.globalState },
_local: { ...localInit },
};
await this.renderFunction({
containerId,
layoutJson: layoutData,
dataContext,
translationContext: {
templateId: this.templateId,
locale: this.locale,
},
});
if (this.debug) {
logger.log(`Error page ${errorCode} rendered successfully`);
}
return true;
} catch (error) {
logger.error(`Failed to render error page ${errorCode}:`, error);
return false;
}
}
/**
* data_sources의 initGlobal/initLocal 옵션을 처리하여
* globalState와 localInit에 fetch된 데이터를 매핑합니다.
*
* TemplateApp.processInitOptions()의 간소화 버전으로,
* 에러 페이지에서 필요한 형태만 지원합니다:
* - 문자열 형태: initGlobal: "currentUser"
* - 배열 형태: initGlobal: ["currentUser", "settings"]
* - 객체 형태: initGlobal: { key: "cartCount", path: "count" }
*
* @since engine-v1.28.1
* @param dataSources 데이터 소스 정의 배열
* @param fetchedData fetch된 데이터 (키: 데이터소스 ID)
* @param localInit _local 상태에 매핑할 데이터 (출력용)
*/
private processInitOptions(
dataSources: DataSource[],
fetchedData: Record<string, unknown>,
localInit: Record<string, unknown>
): void {
for (const source of dataSources) {
const rawData = fetchedData[source.id] as any;
if (!rawData) continue;
// API 응답의 data 필드 추출 (success/data 래핑 해제)
const actualData = rawData?.data ?? rawData;
// initLocal 처리
if (source.initLocal) {
if (typeof source.initLocal === 'string') {
localInit[source.initLocal] = actualData;
logger.log(`initLocal: ${source.id}.data -> _local.${source.initLocal}`);
} else if (typeof source.initLocal === 'object' && 'key' in (source.initLocal as any)) {
const { key, path } = source.initLocal as { key: string; path?: string };
localInit[key] = path ? this.getValueByPath(actualData, path) : actualData;
logger.log(`initLocal: ${source.id}.data${path ? '.' + path : ''} -> _local.${key}`);
}
}
// initGlobal 처리
if (source.initGlobal) {
const items = Array.isArray(source.initGlobal)
? source.initGlobal
: [source.initGlobal];
for (const item of items) {
if (typeof item === 'string') {
this.globalState[item] = actualData;
logger.log(`initGlobal: ${source.id}.data -> _global.${item}`);
} else if (typeof item === 'object' && item !== null && 'key' in item) {
const { key, path } = item as { key: string; path?: string };
this.globalState[key] = path ? this.getValueByPath(actualData, path) : actualData;
logger.log(`initGlobal: ${source.id}.data${path ? '.' + path : ''} -> _global.${key}`);
}
}
}
}
}
/**
* 점 표기법 경로로 중첩 객체의 값을 가져옵니다.
*
* @param obj 대상 객체
* @param path 점 표기법 경로 (예: "data.count")
* @returns 경로에 해당하는 값
*/
private getValueByPath(obj: any, path: string): any {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
}
/**
* 전역 상태를 업데이트합니다.
*
* @param state 새로운 전역 상태
*/
updateGlobalState(state: Record<string, unknown>): void {
this.globalState = { ...this.globalState, ...state };
}
/**
* 로케일을 업데이트합니다.
*
* 언어 변경 시 번역 컨텍스트에 최신 로케일이 반영되도록 합니다.
*
* @param locale 새로운 로케일 (예: 'ko', 'en')
*/
updateLocale(locale: string): void {
this.locale = locale;
if (this.debug) {
logger.log('Locale updated:', locale);
}
}
/**
* 에러 설정이 로드되었는지 확인합니다.
*/
isConfigLoaded(): boolean {
return this.configLoaded;
}
/**
* 특정 에러 코드에 대한 레이아웃이 정의되어 있는지 확인합니다.
*
* @param errorCode 확인할 에러 코드
* @returns 레이아웃 정의 여부
*/
async hasErrorLayout(errorCode: number): Promise<boolean> {
const config = await this.loadConfig();
if (!config) {
return false;
}
return !!(config.layouts[errorCode] || config.layouts[String(errorCode)]);
}
/**
* 캐시된 설정을 초기화합니다.
*/
clearConfigCache(): void {
this.errorConfig = null;
this.configLoaded = false;
if (this.debug) {
logger.log('Config cache cleared');
}
}
}