Files
Gnuboard7/app/Http/Controllers/Api/Public/PublicPluginController.php
T
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무
전수조사에서 확정한 결함군을 닫는다.

## 디버그 라우트 게이트 (F1~F5)

게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에
`File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고,
production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은
User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴
구조였다.

게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로
올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다.
`withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는
제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치
우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다.

## 확장 자산 CSS 상대 참조 (범위 추가 — 결정)

`asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준
디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가
존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘
약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서
통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다.

둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이
정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류
갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 —
갈라지면 한쪽만 고쳐진 채 남는다.

이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가
정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다.

## 회귀 잠금

되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입,
`channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들
치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다.

게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과
구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00

157 lines
6.2 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\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\PluginService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 플러그인 API 컨트롤러
*
* 플러그인 에셋 서빙을 담당합니다.
*/
class PublicPluginController extends PublicBaseController
{
use ServesExtensionBundles;
use ServesRewritableCssAssets;
public function __construct(
private readonly PluginService $pluginService,
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('plugins.bundle', ['kind' => 'js']);
return $this->bundleResponse($this->bundleService, 'plugin', 'js', 'text/javascript');
}
/**
* 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
return $this->bundleResponse($this->bundleService, 'plugin', 'css', 'text/css');
}
/**
* 플러그인 에셋 서빙
*
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServePluginAssetRequest $request,
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('plugins.assets', ['identifier' => $identifier, 'path' => $path]);
// Service에서 파일 경로 조회 (검증은 FormRequest에서 완료됨)
$result = $this->pluginService->getAssetFilePath($identifier, $path);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'plugin_not_found' => $this->notFound(__('plugins.errors.not_found', ['plugin' => $identifier])),
'file_not_found' => $this->notFound(__('plugins.errors.file_not_found')),
'file_type_not_allowed' => $this->forbidden(__('plugins.errors.file_type_not_allowed')),
default => $this->error(__('plugins.errors.unknown_error'), 500),
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'plugins',
$identifier,
$path,
31536000
);
}
/**
* 플러그인 편집기 스펙 조회 — editor-spec.json 반환
*
* 활성 플러그인만 대상으로 하며, 활성 디렉토리 → _bundled 폴백 순으로 읽어
* 템플릿 serveEditorSpec 과 동일한 응답 형태(`data.spec`)로 반환한다.
* 비활성/미존재 플러그인은 404. 파일 미작성은 spec=null 정상 응답.
*
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('plugins.editor_spec', ['identifier' => $identifier]);
$result = $this->pluginService->getEditorSpec($identifier);
if (! $result['success']) {
return $this->notFound(__('plugins.errors.not_found', ['plugin' => $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('plugins.components', ['identifier' => $identifier]);
$result = $this->pluginService->getComponents($identifier);
if (! $result['success']) {
return $this->notFound(__('plugins.errors.not_found', ['plugin' => $identifier]));
}
return $this->cachedJsonResponse($result['components'] ?? new \stdClass, 3600);
}
}