Files
Gnuboard7/app/Http/Controllers/Api/Public/PublicModuleController.php
T
HeuJung b4ed33aa44 feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을
지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는
결함을 전수조사로 함께 고친다.

- 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false`
 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다
- 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w
 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다
- 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑,
 rename 일시 거부 재시도, 프론트 JSON 파싱 검증
- 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림
 + ext-static:status 점검 커맨드
- 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락),
 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
2026-08-28 08:02:22 +09:00

148 lines
5.9 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesExtensionBundles;
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
{
use ServesExtensionBundles;
public function __construct(
private readonly ModuleService $moduleService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
* 에셋을 선언한 활성 확장이 0개면 빈 200, 선언은 있는데 결과가 비면 503 —
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
return $this->bundleResponse($this->bundleService, 'module', 'js', 'text/javascript');
}
/**
* 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
return $this->bundleResponse($this->bundleService, 'module', 'css', 'text/css');
}
/**
* 모듈 에셋 서빙
*
* @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|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
public function serveComponents(string $identifier): JsonResponse|Response
{
$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);
}
}