Files
Gnuboard7/app/Http/Controllers/Api/Public/PublicModuleController.php
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

158 lines
6.1 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 모듈 API 컨트롤러
*
* 모듈 에셋 서빙을 담당합니다.
*/
class PublicModuleController extends PublicBaseController
{
public function __construct(
private readonly ModuleService $moduleService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
* 서빙(ETag/304/환경별 Cache-Control 재사용).
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
}
/**
* 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
}
/**
* 모듈 에셋 서빙
*
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServeModuleAssetRequest $request,
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('modules.assets', ['identifier' => $identifier, 'path' => $path]);
// Service에서 파일 경로 조회 (검증은 FormRequest에서 완료됨)
$result = $this->moduleService->getAssetFilePath($identifier, $path);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'module_not_found' => $this->notFound(__('modules.errors.not_found', ['module' => $identifier])),
'file_not_found' => $this->notFound(__('modules.errors.file_not_found')),
'file_type_not_allowed' => $this->forbidden(__('modules.errors.file_type_not_allowed')),
default => $this->error(__('modules.errors.unknown_error'), 500),
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
/**
* 모듈 편집기 스펙 조회 — editor-spec.json 반환
*
* 활성 모듈만 대상으로 하며, 활성 디렉토리 → _bundled 폴백 순으로 읽어
* 템플릿 serveEditorSpec 과 동일한 응답 형태(`data.spec`)로 반환한다.
* 비활성/미존재 모듈은 404. 파일 미작성은 spec=null 정상 응답.
*
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('modules.editor_spec', ['identifier' => $identifier]);
$result = $this->moduleService->getEditorSpec($identifier);
if (! $result['success']) {
return $this->notFound(__('modules.errors.not_found', ['module' => $identifier]));
}
$message = $result['spec'] === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success($message, [
'identifier' => $identifier,
'spec' => $result['spec'],
]);
}
/**
* 모듈 컴포넌트 정의 파일 서빙 — components.json 반환
*
* 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스
* 병합하기 위해 fetch 한다. 미생성(구버전 모듈) 시 빈 components 로
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 모듈 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
$result = $this->moduleService->getComponents($identifier);
if (! $result['success']) {
return $this->notFound(__('modules.errors.not_found', ['module' => $identifier]));
}
return $this->cachedJsonResponse($result['components'] ?? new \stdClass, 3600);
}
}