공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전 디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접 서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms), 미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다. - 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄, sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외 - 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거), fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트, ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환 - 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control, 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지) - SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설, 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류 - 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3, 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋 이력 전수 재조사 반영 - 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
157 lines
6.1 KiB
PHP
157 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Public;
|
|
|
|
use App\Http\Controllers\Api\Base\PublicBaseController;
|
|
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
|
|
{
|
|
public function __construct(
|
|
private readonly PluginService $pluginService,
|
|
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('plugins.bundle', ['kind' => 'js']);
|
|
|
|
$version = $this->bundleService->getCurrentVersion();
|
|
$path = $this->bundleService->getBundleFilePath('plugin', '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('plugins.bundle', ['kind' => 'css']);
|
|
|
|
$version = $this->bundleService->getCurrentVersion();
|
|
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
|
|
|
|
if ($path === '') {
|
|
return response('', 200)->header('Content-Type', 'text/css');
|
|
}
|
|
|
|
return $this->fileResponse($path, 'text/css', 31536000);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 에셋 서빙
|
|
*
|
|
* @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->fileResponse($result['filePath'], $result['mimeType'], 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);
|
|
}
|
|
}
|