v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

@@ -0,0 +1,484 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PermissionService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* 레이아웃 편집기용 어드민 템플릿 자산 서빙 컨트롤러
*
* 설치돼 있으나 비활성 상태인 템플릿도 편집할 수 있어야 한다.
* `PublicTemplateController` 의 모든 서빙 메서드는 비활성 템플릿을 차단하므로,
* 편집기 부팅용 admin 경로를 별도 제공한다.
*
* 5개 엔드포인트 — 모두 admin 권한 가드 (`core.templates.layouts.edit`):
* - getEditorAssets: 자산 매니페스트 (IIFE/CSS URL) — bootstrap.ts 가 조건부 로드
* - serveComponents: components.json (정상)
* - serveRoutes: routes.json (활성 상태 무관)
* - serveEditorSpec: editor-spec.json
* - serveLanguage: 다국어 데이터 (활성 상태 무관)
*
* 모두 활성/비활성 무관 200 응답. public 경로(`PublicTemplateController`) 는
* 종전대로 활성만 허용 (일반 사이트 보안 유지).
*/
class AdminTemplateAssetController extends AdminBaseController
{
public function __construct(
private TemplateService $templateService,
private PermissionService $permissionService,
) {
parent::__construct();
}
/**
* 편집 자산 매니페스트 — IIFE / CSS URL 목록 반환.
*
* bootstrap.ts 가 비활성 템플릿 부팅 시 본 응답의 js/css 를 동적으로 주입.
* 활성 템플릿은 코어 일반 부팅이 이미 자산을 로드하므로 본 엔드포인트는
* 비활성 템플릿 케이스에서 주로 사용된다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 자산 매니페스트 응답
*/
public function getEditorAssets(string $identifier): JsonResponse
{
// 빌드 결과물 위치 — 활성 디렉토리 우선, _bundled 폴백
// 실제 IIFE/CSS 는 `php artisan template:build` 가 `dist/js/components.iife.js` /
// `dist/css/components.css` 에 산출하며, `/api/templates/assets/{id}/...` 라우트가
// 활성/_bundled 자동 폴백 + 권한 가드를 거쳐 서빙한다 (resources/views/admin.blade.php
// 의 자산 URL 패턴과 동일).
$iifeCandidates = [
base_path("templates/{$identifier}/dist/js/components.iife.js"),
base_path("templates/_bundled/{$identifier}/dist/js/components.iife.js"),
];
$cssCandidates = [
base_path("templates/{$identifier}/dist/css/components.css"),
base_path("templates/_bundled/{$identifier}/dist/css/components.css"),
];
$jsAvailable = false;
$jsSource = null;
foreach ($iifeCandidates as $candidate) {
if (file_exists($candidate)) {
$jsAvailable = true;
$jsSource = str_contains($candidate, '/_bundled/') ? 'bundled' : 'active';
break;
}
}
$cssAvailable = false;
foreach ($cssCandidates as $candidate) {
if (file_exists($candidate)) {
$cssAvailable = true;
break;
}
}
if (! $jsAvailable) {
return $this->success(
__('templates.messages.editor_assets_missing'),
['identifier' => $identifier, 'js' => [], 'css' => [], 'manifest_present' => false],
);
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? "?v={$extensionCacheVersion}" : '';
return $this->success(
__('templates.messages.editor_assets_retrieved'),
[
'identifier' => $identifier,
'js' => ["/api/templates/assets/{$identifier}/js/components.iife.js{$version}"],
// CSS 는 편집기 전용 엔드포인트로 — 다크 셀렉터를 프리뷰 마커로 치환해 서빙
// 일반 자산 서빙은 원본.
'css' => $cssAvailable
? ["/api/admin/templates/{$identifier}/editor/components.css{$version}"]
: [],
'manifest_present' => true,
'manifest_source' => $jsSource,
],
);
}
/**
* components.json 서빙 (활성/비활성 무관).
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$candidates = [
base_path("templates/{$identifier}/components.json"),
base_path("templates/_bundled/{$identifier}/components.json"),
];
foreach ($candidates as $path) {
if (file_exists($path)) {
$data = json_decode((string) file_get_contents($path), true);
if (is_array($data)) {
return $this->success(__('templates.messages.config_retrieved'), $data);
}
}
}
return $this->error(__('templates.layout_not_found'), 404);
}
/**
* routes.json 서빙 (활성/비활성 무관) — 편집기 라우트 트리용.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 라우트 정의 응답
*/
public function serveRoutes(string $identifier): JsonResponse
{
// 편집기 라우트 트리는 각 라우트의 `source`(`{kind, identifier}`) 태깅에 의존한다
// (useRouteTree.buildRouteTree 가 `route.source.kind` 로 그룹핑). raw routes.json
// 을 그대로 반환하면 source 가 없어 클라이언트가 `undefined.kind` 접근에서 throw →
// 라우트 트리 전체가 network 에러로 무너진다. public getRoutes 와 동일한 source
// 태깅 + 모듈/플러그인 병합을 활성/비활성 무관 + _bundled 폴백으로 수행한다.
$result = $this->templateService->getEditorRoutesDataWithModules($identifier);
if (! $result['success']) {
return match ($result['error']) {
'routes_not_found' => $this->error(__('templates.messages.routes_not_found'), 404),
'invalid_json' => $this->error(__('templates.errors.invalid_json'), 422),
default => $this->error(__('templates.messages.routes_not_found'), 404),
};
}
return $this->success(__('templates.messages.routes_retrieved'), $result['data']);
}
/**
* 편집기 스펙 서빙 — editor-spec.json 파일 반환 (활성/비활성 무관).
*
* 활성 디렉토리 → _bundled 폴백 순으로 editor-spec.json 을 읽어 반환한다.
* Phase 4 S6-1 부터 응답에 sampleData/sampleGlobal/states 등 전 블록이 포함된다
* ("골격 → 정식 응답" 이행). 파일 미작성 시 spec=null 폴백.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
// 분할 editor-spec.json 은 manifest + `$include` 블록으로 구성된다.
// 활성 디렉토리만 기준으로 합본한 단일 spec 을 반환한다(_bundled 폴백 없음).
// 비활성 템플릿도 편집 가능하므로 status 가드는 없다. 미분할 파일은
// 원본 반환(하위 호환), 미존재 시 null.
$spec = EditorSpecAssembler::assemble(
base_path("templates/{$identifier}/editor-spec.json")
);
$message = $spec === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success($message, ['identifier' => $identifier, 'spec' => $spec]);
}
/**
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
*
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
* 일반 사용자 페이지 CSS(public serveAsset)는 원본 그대로 → 사용자 페이지 100% 무영향.
*
* 치환 규칙은 템플릿 editor-spec 의 `darkMode.previewIsolation`(rewriteSelector →
* replaceWith)에서 가져온다(라이브러리 중립). 미선언/`strategy:none` 이면 원본 그대로.
* 변환 결과는 템플릿+확장 캐시 버전 키로 캐시(대용량 CSS 매요청 재가공 방지). 치환 실패
* 또는 CSS 부재 시 원본/빈 응답으로 안전 폴백한다.
*
* @param string $identifier 템플릿 식별자
* @return Response 변환된 CSS 응답 (text/css)
*/
public function serveEditorCss(string $identifier)
{
$cssCandidates = [
base_path("templates/{$identifier}/dist/css/components.css"),
base_path("templates/_bundled/{$identifier}/dist/css/components.css"),
];
$cssPath = null;
foreach ($cssCandidates as $candidate) {
if (file_exists($candidate)) {
$cssPath = $candidate;
break;
}
}
if ($cssPath === null) {
// CSS 부재 — 빈 CSS 폴백(편집기 부팅이 실패하지 않도록 200).
return response('', 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
// 코어 소유 키(`template.*`)는 CoreCacheDriver 직접 생성으로 다룬다 — `app(CacheInterface)`
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
// 코어 캐시 store 를 빗나갈 수 있다.
$cache = new CoreCacheDriver;
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
$mtime = (int) @filemtime($cssPath);
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
$cached = $cache->get($cacheKey);
if (is_string($cached)) {
return response($cached, 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
$css = (string) file_get_contents($cssPath);
// editor-spec 의 darkMode.previewIsolation 로 치환 규칙 해석.
$isolation = $this->resolveDarkPreviewIsolation($identifier);
if ($isolation !== null) {
$css = $this->rewriteDarkSelectors($css, $isolation['rewrite'], $isolation['replace']);
// @layer 평탄화 — CSS cascade-layer 를 쓰는 라이브러리(예 Tailwind v4)에서, 편집기
// 프리뷰 CSS 가 어드민 호스트 CSS 와 같은 `@layer` 이름을 공유하면 cross-build 레이어
// 우선순위 충돌로 프리뷰의 다크 규칙이 적용되지 않는다(브라우저 실측 확인). 편집기 CSS 는
// 프리뷰 전용 + 마지막 로드이므로, 레이어 래퍼를 제거해 전부 unlayered(최고 우선순위,
// 파일 내 소스 순서 보존)로 만들어 호스트 레이어드 규칙을 확실히 이긴다. 라이브러리
// 특성이므로 템플릿이 `previewIsolation.flattenLayers: true` 로 명시 옵트인할 때만 수행한다
// (라이브러리 중립 — @layer 비사용 CSS 는 평탄화 불필요).
if ($isolation['flattenLayers']) {
$css = $this->flattenCssLayers($css);
}
}
// 변환 결과 캐시(실패해도 원본을 그대로 캐시 — 매요청 재가공 방지).
$cache->put($cacheKey, $css, 3600);
return response($css, 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
/**
* 템플릿 editor-spec 의 `darkMode.previewIsolation` 치환 규칙 해석.
*
* @param string $identifier 템플릿 식별자
* @return array{rewrite: string, replace: string, flattenLayers: bool}|null 치환 규칙, 미선언/none 이면 null
*/
private function resolveDarkPreviewIsolation(string $identifier): ?array
{
$candidates = [
base_path("templates/{$identifier}/editor-spec.json"),
base_path("templates/_bundled/{$identifier}/editor-spec.json"),
];
foreach ($candidates as $path) {
if (! file_exists($path)) {
continue;
}
$spec = json_decode((string) file_get_contents($path), true);
if (! is_array($spec)) {
continue;
}
$dark = $spec['darkMode'] ?? null;
if (! is_array($dark)) {
return null;
}
$strategy = $dark['strategy'] ?? null;
if ($strategy === 'none') {
return null;
}
$iso = $dark['previewIsolation'] ?? null;
if (! is_array($iso)) {
return null;
}
$rewrite = $iso['rewriteSelector'] ?? null;
$replace = $iso['replaceWith'] ?? null;
if (is_string($rewrite) && $rewrite !== '' && is_string($replace) && $replace !== '') {
return [
'rewrite' => $rewrite,
'replace' => $replace,
// @layer 평탄화는 라이브러리 특성(CSS @layer cascade-layer 사용 시 cross-build
// 우선순위 충돌)이라 템플릿이 명시 옵트인할 때만 수행한다(라이브러리 중립).
'flattenLayers' => ($iso['flattenLayers'] ?? false) === true,
];
}
return null;
}
return null;
}
/**
* CSS 텍스트의 다크 조상 셀렉터를 프리뷰 마커로 안전 치환.
*
* 조상 셀렉터(`.dark` 가 후손 결합자/그룹 경계 앞)만 치환하고, 유틸리티 클래스
* 자체(`.dark\:bg-x` — 이스케이프 콜론)는 건드리지 않는다. 경계 = 공백/`,`/`)`/`{`/`>`/`~`/`+`.
*
* @param string $css 원본 CSS
* @param string $rewrite 원본 다크 셀렉터(예 `.dark`)
* @param string $replace 치환 마커(예 `.g7le-preview-dark`)
* @return string 치환된 CSS (치환 실패 시 원본)
*/
private function rewriteDarkSelectors(string $css, string $rewrite, string $replace): string
{
// `.dark` 다음에 셀렉터 경계 문자(공백/조합자/그룹 경계)가 오는 경우만 치환.
// `.dark\:` (유틸리티 클래스 — 이스케이프된 콜론)는 lookahead 가 `\` 라 매칭 안 됨.
$pattern = '/'.preg_quote($rewrite, '/').'(?=[\s,){>~+])/';
$result = preg_replace($pattern, $replace, $css);
// preg 실패(null) 시 원본 폴백 — 프리뷰가 깨지지 않도록.
return is_string($result) ? $result : $css;
}
/**
* CSS 의 `@layer NAME { ... }` 블록 래퍼를 제거해 내부 규칙을 unlayered 로 평탄화한다.
*
* Tailwind v4 는 규칙을 `@layer theme/base/components/utilities` 로 감싼다. 편집기
* 프리뷰 CSS 와 어드민 호스트 CSS 가 같은 레이어 이름을 쓰면 cross-build 레이어 우선순위
* 충돌로 프리뷰 규칙이 적용되지 않을 수 있다. 편집기 CSS 는 프리뷰 전용 + 마지막 로드라
* 권위를 가져야 하므로, 레이어 블록 래퍼만 벗겨 내부 규칙을 unlayered 로 만든다(unlayered >
* layered). 규칙 자체와 소스 순서는 보존하므로 파일 내 cascade 는 불변.
*
* 중괄호 균형 스캐너로 `@layer <names> {` 의 짝 `}` 만 찾아 제거한다(정규식은 중첩 중괄호를
* 안전히 매칭 못 함). `@layer <names>;`(선언만, 블록 없음)은 그대로 둔다(레이어 순서 선언).
*
* @param string $css 치환 완료된 CSS
* @return string 레이어 래퍼가 제거된 CSS
*/
private function flattenCssLayers(string $css): string
{
// 중첩 `@layer` 대비 — 더 이상 블록 오프너가 없을 때까지 반복(최대 8회, 폭주 방지).
for ($pass = 0; $pass < 8; $pass++) {
if (! preg_match('/@layer\s+[^;{]*\{/', $css)) {
break;
}
$css = $this->flattenCssLayersOnce($css);
}
return $css;
}
/**
* `@layer NAME { ... }` 블록 래퍼 1패스 제거(중괄호 균형 스캔). flattenCssLayers 가 반복 호출.
*
* @param string $css CSS
* @return string 1패스 평탄화 결과
*/
private function flattenCssLayersOnce(string $css): string
{
$out = '';
$len = strlen($css);
$i = 0;
// `@layer` 다음에 이름 목록 + `{` 가 오는 블록 오프너를 찾는다.
while ($i < $len) {
// `@layer` 리터럴 탐색
$at = strpos($css, '@layer', $i);
if ($at === false) {
$out .= substr($css, $i);
break;
}
// `@layer` 앞부분 그대로 출력
$out .= substr($css, $i, $at - $i);
// `@layer` 뒤 ~ 다음 `{` 또는 `;` 까지 — 이름 목록.
$j = $at + 6; // strlen('@layer')
$brace = strpos($css, '{', $j);
$semi = strpos($css, ';', $j);
// 블록 없는 선언(`@layer a, b;`)이면 `;` 가 먼저 — 그대로 보존.
if ($semi !== false && ($brace === false || $semi < $brace)) {
$out .= substr($css, $at, $semi - $at + 1);
$i = $semi + 1;
continue;
}
if ($brace === false) {
// 형식 이상 — 남은 전체 출력 후 종료(폴백).
$out .= substr($css, $at);
break;
}
// `@layer <names> {` 블록 — 오프너(`@layer ... {`)는 버리고 내부를 평탄화.
// 짝 `}` 를 중괄호 균형으로 찾는다.
$depth = 1;
$k = $brace + 1;
while ($k < $len && $depth > 0) {
$ch = $css[$k];
if ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
break;
}
}
$k++;
}
if ($depth !== 0) {
// 짝 불일치 — 평탄화 포기, 원문 그대로 출력 후 종료(안전 폴백).
$out .= substr($css, $at);
break;
}
// 내부 내용(오프너 `{` 다음 ~ 짝 `}` 직전)만 채택 — 레이어 래퍼 제거.
$out .= substr($css, $brace + 1, $k - ($brace + 1));
$i = $k + 1; // 짝 `}` 다음부터 계속
}
return $out;
}
/**
* 다국어 데이터 서빙 (활성/비활성 무관).
*
* `TemplateService::getLanguageDataWithModules` 는 활성 검증이 있으므로,
* 편집기 admin 경로는 본 메서드에서 활성 검증을 우회한 폴백 로드를 수행한다.
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일
* @return JsonResponse 다국어 데이터 응답
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
{
$candidates = [
base_path("templates/{$identifier}/lang/{$locale}.json"),
base_path("templates/_bundled/{$identifier}/lang/{$locale}.json"),
];
foreach ($candidates as $path) {
if (file_exists($path)) {
$data = json_decode((string) file_get_contents($path), true);
if (is_array($data)) {
return $this->success(__('templates.messages.language_retrieved'), $data);
}
}
}
// 다국어 파일 부재 — 빈 객체 폴백
return $this->success(__('templates.messages.language_empty'), []);
}
/**
* 레이아웃 편집기 표시 권한 후보 서빙.
*
* 코어 + 활성 확장 권한 전체를 `{key, name}` 목록으로 반환한다. 편집기 진입 권한
* (`core.templates.layouts.edit`) 가드 하에서만 노출되므로, 권한 카탈로그가 모든
* admin 페이지에 상시 노출되던 종전 방식(`G7Config.permissions`)보다 노출 범위가
* 편집기로 한정된다. 후보 미존재/조회 실패 시 빈 목록 → TagInput "+ 추가" 디그레이드.
*
* @param string $identifier 템플릿 식별자 (라우트 일관성용 — 후보는 전역 권한)
* @return JsonResponse 권한 후보 목록 응답
*/
public function servePermissionCandidates(string $identifier): JsonResponse
{
$candidates = $this->permissionService->getPermissionCandidates(app()->getLocale());
return $this->success(
__('templates.messages.config_retrieved'),
['identifier' => $identifier, 'permissions' => $candidates],
);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\Template\ListTemplateLayoutAttachmentsRequest;
use App\Http\Requests\Admin\Template\UploadTemplateLayoutAttachmentRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
use Illuminate\Http\JsonResponse;
/**
* 템플릿 레이아웃 첨부 파일 어드민 컨트롤러
*
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 제공한다.
* ImagePickerControl(11.2.2)이 본 API 를 호출한다. 권한은 라우트의 permission
* 미들웨어(core.templates.layouts.edit)가 담당한다.
*/
class AdminTemplateLayoutAttachmentController extends AdminBaseController
{
public function __construct(
private TemplateLayoutAttachmentService $service,
) {
parent::__construct();
}
/**
* 첨부 파일 목록 조회 — 그 템플릿의 첨부(이미지 재선택용).
*
* @param ListTemplateLayoutAttachmentsRequest $request 검증된 요청 (layout_name 쿼리 선택)
* @param string $identifier 템플릿 식별자
* @return JsonResponse 첨부 목록 응답
*/
public function index(ListTemplateLayoutAttachmentsRequest $request, string $identifier): JsonResponse
{
$layoutName = $request->validated('layout_name');
$result = $this->service->list($identifier, is_string($layoutName) ? $layoutName : null);
if (! $result['success']) {
return $this->notFound(__('templates.errors.not_found', ['template' => $identifier]));
}
$items = $result['attachments']->map(fn (TemplateLayoutAttachment $a) => [
'id' => $a->id,
'layout_name' => $a->layout_name,
'original_name' => $a->original_name,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => $this->service->resolveUrl($a),
'created_at' => $a->created_at?->toIso8601String(),
])->all();
return $this->success(__('templates.layout_attachments.messages.listed'), $items);
}
/**
* 첨부 파일 업로드 → 스토리지 저장 + 행 생성 → 접근 URL 반환.
*
* @param UploadTemplateLayoutAttachmentRequest $request 검증된 요청
* @param string $identifier 템플릿 식별자
* @return JsonResponse 업로드 결과 응답
*/
public function store(UploadTemplateLayoutAttachmentRequest $request, string $identifier): JsonResponse
{
$result = $this->service->upload(
$identifier,
$request->file('file'),
$request->input('layout_name'),
);
if (! $result['success']) {
return match ($result['error']) {
'template_not_found' => $this->notFound(__('templates.errors.not_found', ['template' => $identifier])),
default => $this->error(__('templates.layout_attachments.errors.upload_failed'), 500),
};
}
$attachment = $result['attachment'];
return $this->success(__('templates.layout_attachments.messages.uploaded'), [
'id' => $attachment->id,
'layout_name' => $attachment->layout_name,
'original_name' => $attachment->original_name,
'mime_type' => $attachment->mime_type,
'size' => $attachment->size,
'url' => $result['url'],
]);
}
/**
* 첨부 파일 삭제 — 스토리지 파일 실삭제 + DB 행 삭제.
*
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(TemplateLayoutAttachment $attachment): JsonResponse
{
$deleted = $this->service->delete($attachment);
if (! $deleted) {
return $this->error(__('templates.layout_attachments.errors.delete_failed'), 500);
}
return $this->success(__('templates.layout_attachments.messages.deleted'), null);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Seo\Editor\BroadcastCatalogService;
use Illuminate\Http\JsonResponse;
/**
* 웹소켓 채널/이벤트 카탈로그 엔드포인트 — 데이터소스 websocket 후보.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(권한/SEO 후보 엔드포인트와
* 동일 패턴 — admin 전역 broadcast 회피, Bearer fetch). 등록 채널 + (동적) 이벤트 목록을
* 반환한다. 미응답/빈 목록 시 편집기는 자유 텍스트 폴백.
*/
class BroadcastCatalogController extends AdminBaseController
{
public function __construct(
private readonly BroadcastCatalogService $catalogService,
) {
parent::__construct();
}
/**
* 채널/이벤트 카탈로그를 반환합니다.
*
* @param string $identifier 템플릿 식별자(라우트 일관성 — 카탈로그는 설치본 전역)
* @return JsonResponse channels / events 응답
*/
public function index(string $identifier): JsonResponse
{
$catalog = $this->catalogService->collect();
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $catalog),
);
}
}
@@ -90,4 +90,22 @@ class DashboardController extends AdminBaseController
return $this->error('dashboard.alerts_failed', 500, $e->getMessage());
}
}
/**
* 최근 발송된 알림 이력을 조회합니다.
*
* 대시보드 "최근 알림" 카드에 표시할 최근 알림 발송 이력을 반환합니다.
*
* @return JsonResponse 최근 알림 이력을 포함한 JSON 응답
*/
public function recentNotifications(): JsonResponse
{
try {
$notifications = $this->dashboardService->getRecentNotificationLogs();
return $this->success('dashboard.recent_notifications_loaded', $notifications);
} catch (\Exception $e) {
return $this->error('dashboard.recent_notifications_failed', 500, $e->getMessage());
}
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\Identity;
use App\Enums\IdentityPolicySourceType;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Identity\AdminIdentityPolicyDestroyRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyIndexRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyResetFieldRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyStoreRequest;
@@ -12,23 +13,27 @@ use App\Http\Resources\Identity\PolicyCollection;
use App\Http\Resources\Identity\PolicyResource;
use App\Services\IdentityPolicyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 관리자 — IDV 정책 CRUD 컨트롤러.
*
* S1d 서브섹션(DataGrid + 편집 모달)을 위한 API.
* 선언형 정책(source_type != 'admin')은 enabled/grace_minutes/provider_id/fail_mode/conditions 5개 필드만
* 편집 가능하며, 편집 시 user_overrides JSON 에 필드명이 append 되어 Seeder 재실행 시 보존됩니다.
* 선언형 정책(source_type != 'admin')은 키(key)/시점(scope)/위치(target) 만 readonly 이며,
* 그 외 필드(purpose/provider_id/grace_minutes/applies_to/priority/fail_mode/enabled/conditions)는
* 운영자가 자유로이 편집할 수 있습니다. 편집 시 user_overrides JSON 에 필드명이 append 되어
* Seeder 재실행 시 보존됩니다.
*/
class AdminIdentityPolicyController extends AdminBaseController
{
/**
* source_type = core/module/plugin 정책이 수정 가능한 필드 화이트리스트.
*
* key/scope/target 은 확장이 발행하는 훅/라우트 지점 식별자라 변경 시 정책이 실제 지점과
* 어긋나므로 제외한다. 나머지("어떻게 인증할지")는 운영자 자유 편집 대상이다.
*
* @var list<string>
*/
protected const LIMITED_EDITABLE_FIELDS = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions'];
protected const LIMITED_EDITABLE_FIELDS = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions', 'purpose', 'applies_to', 'priority'];
/**
* @param IdentityPolicyService $policyService 정책 유스케이스 Service
@@ -128,11 +133,11 @@ class AdminIdentityPolicyController extends AdminBaseController
/**
* 정책을 삭제합니다 (source_type='admin' 정책만 가능, 선언형 정책은 비활성화로 대체).
*
* @param Request $request HTTP 요청
* @param AdminIdentityPolicyDestroyRequest $request 검증된 요청
* @param int $id 정책 ID
* @return JsonResponse
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(Request $request, int $id): JsonResponse
public function destroy(AdminIdentityPolicyDestroyRequest $request, int $id): JsonResponse
{
$policy = $this->policyService->findById($id);
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
@@ -26,8 +27,8 @@ class LayoutController extends AdminBaseController
/**
* 특정 템플릿의 모든 레이아웃 목록 조회
*
* @param string $templateName 템플릿 identifier
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @return JsonResponse 레이아웃 목록 응답
*/
public function index(string $templateName): JsonResponse
{
@@ -39,18 +40,24 @@ class LayoutController extends AdminBaseController
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
return $this->success(
'common.success',
LayoutResource::collection($layouts)
// 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다.
$routePathMap = $this->templateService->getLayoutRoutePathMap($templateName);
$collection = LayoutResource::collection($layouts);
$collection->collection->transform(
fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap)
);
return $this->success('common.success', $collection);
}
/**
* 특정 레이아웃 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 레이아웃 상세 응답
*/
public function show(string $templateName, string $name): JsonResponse
{
@@ -75,10 +82,10 @@ class LayoutController extends AdminBaseController
/**
* 레이아웃 수정
*
* @param UpdateLayoutContentRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param UpdateLayoutContentRequest $request 레이아웃 수정 요청
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 수정된 레이아웃 응답
*/
public function update(UpdateLayoutContentRequest $request, string $templateName, string $name): JsonResponse
{
@@ -99,6 +106,20 @@ class LayoutController extends AdminBaseController
'common.success',
new LayoutResource($layout)
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (\Exception $e) {
DB::rollBack();
@@ -113,9 +134,9 @@ class LayoutController extends AdminBaseController
/**
* 레이아웃의 모든 버전 목록 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, string $name): JsonResponse
{
@@ -142,10 +163,10 @@ class LayoutController extends AdminBaseController
/**
* 특정 버전의 레이아웃 content 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $version 버전 번호
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $version 버전 번호
* @return JsonResponse 버전 content 응답
*/
public function showVersion(string $templateName, string $name, int $version): JsonResponse
{
@@ -163,17 +184,18 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutVersionResource($layoutVersion)
// 버전 비교 diff 용 — content 원본 전체(slots/extends 등 포함) 노출.
(new LayoutVersionResource($layoutVersion))->withFullContent()
);
}
/**
* 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $versionId 버전 ID
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $versionId 버전 ID
* @return JsonResponse 복원된 버전 응답
*/
public function restoreVersion(string $templateName, string $name, int $versionId): JsonResponse
{
@@ -200,10 +222,10 @@ class LayoutController extends AdminBaseController
*
* 편집 중인 레이아웃 content를 임시 저장하고 미리보기 URL을 반환합니다.
*
* @param StoreLayoutPreviewRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param StoreLayoutPreviewRequest $request 미리보기 생성 요청
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 미리보기 토큰/URL 응답
*/
public function storePreview(StoreLayoutPreviewRequest $request, string $templateName, string $name): JsonResponse
{
@@ -225,7 +247,7 @@ class LayoutController extends AdminBaseController
'common.success',
[
'token' => $preview->token,
'preview_url' => '/preview/' . $preview->token,
'preview_url' => '/preview/'.$preview->token,
'expires_at' => $preview->expires_at->toIso8601String(),
]
);
@@ -0,0 +1,315 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutExtensionPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutExtensionContentRequest;
use App\Http\Resources\LayoutExtensionResource;
use App\Http\Resources\LayoutExtensionVersionResource;
use App\Models\LayoutExtension;
use App\Services\LayoutExtensionService;
use App\Services\LayoutPreviewService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 레이아웃 확장 관리 컨트롤러 (admin)
*
* 모듈/플러그인이 주입한 레이아웃 확장을 관리자가 편집·버전관리·미리보기할 수 있도록 합니다.
* 확장은 동일 target_name 에 여러 source 가 존재할 수 있어 정수 PK(extensionId)로 식별합니다.
*/
class LayoutExtensionController extends AdminBaseController
{
public function __construct(
private LayoutExtensionService $layoutExtensionService,
private TemplateService $templateService,
private LayoutPreviewService $layoutPreviewService
) {
parent::__construct();
}
/**
* 특정 템플릿의 레이아웃 확장 목록 조회 (출처별 그룹핑)
*
* @param string $templateName 템플릿 identifier
* @return JsonResponse 출처별 그룹핑된 확장 목록
*/
public function index(string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$groups = $this->layoutExtensionService->getExtensionsByTemplateId($template->id);
// 그룹별 extensions 를 LayoutExtensionResource 컬렉션으로 직렬화.
// 각 확장에 호스트 레이아웃 목록(host_layouts)을 부착해, 라우트 트리가 클릭(캔버스 로드)
// 없이도 layoutName 매칭으로 화면별 연결 확장 목록을 정적 구성하게 한다.
$data = array_map(function (array $group): array {
foreach ($group['extensions'] as $extension) {
$extension->setAttribute(
'host_layouts',
$this->layoutExtensionService->getExtensionHostLayouts($extension)
);
}
$group['extensions'] = LayoutExtensionResource::collection($group['extensions']);
return $group;
}, $groups);
return $this->success('common.success', $data);
}
/**
* 특정 레이아웃 확장 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 확장 상세 응답
*/
public function show(string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 호스트 레이아웃 후보 — 확장 편집 모드 캔버스가 호스트 병합
// 렌더할 대상. overlay = [target_layout], extension_point = 그 확장점을 포함하는
// 레이아웃 전체(복수면 클라이언트가 대표 호스트 선택 picker 를 띄운다).
// ResponseHelper::success 는 Resource additional() 을 보존하지 않으므로 data 안에 병합.
$payload = (new LayoutExtensionResource($extension))->resolve();
$payload['host_layouts'] = $this->layoutExtensionService->getExtensionHostLayouts($extension);
return $this->success('common.success', $payload);
}
/**
* 레이아웃 확장 content 수정
*
* @param UpdateLayoutExtensionContentRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 업데이트된 확장 응답
*/
public function update(UpdateLayoutExtensionContentRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
// content 는 input() 으로 전체 배열을 전달한다.
// validated() 는 content.* 하위 규칙이 있을 경우 하위 키만 재구성하여
// extension_point/components 등 최상위 키가 누락되기 때문이다.
$data = [
'content' => $request->input('content'),
];
if ($request->has('priority')) {
$data['priority'] = $request->input('priority');
}
if ($request->has('expected_lock_version')) {
$data['expected_lock_version'] = $request->input('expected_lock_version');
}
$updated = $this->layoutExtensionService->updateExtension($extensionId, $data);
DB::commit();
return $this->success('common.success', new LayoutExtensionResource($updated));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장의 모든 버전 목록 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
$versions = $this->layoutExtensionService->getExtensionVersions($extensionId);
return $this->success('common.success', LayoutExtensionVersionResource::collection($versions));
}
/**
* 특정 버전의 레이아웃 확장 content 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $version 버전 번호
* @return JsonResponse 특정 버전 응답
*/
public function showVersion(string $templateName, int $extensionId, int $version): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
$extensionVersion = $this->layoutExtensionService->getExtensionVersion($extensionId, $version);
} catch (ModelNotFoundException) {
return $this->notFound('common.not_found');
}
return $this->success('common.success', new LayoutExtensionVersionResource($extensionVersion));
}
/**
* 레이아웃 확장 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $versionId 복원할 버전 ID
* @return JsonResponse 복원 후 새 버전 응답
*/
public function restoreVersion(string $templateName, int $extensionId, int $versionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$newVersion = $this->layoutExtensionService->restoreExtensionVersion($extensionId, $versionId);
DB::commit();
return $this->success('common.success', new LayoutExtensionVersionResource($newVersion));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장 미리보기 생성
*
* 편집 중인 확장 content를 임시 저장하고, 대표 레이아웃에 적용한 미리보기 URL을 반환합니다.
*
* @param StoreLayoutExtensionPreviewRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 미리보기 토큰 응답
*/
public function storePreview(StoreLayoutExtensionPreviewRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 대표 레이아웃 결정:
// - overlay 타입은 target_name 자체가 대표 레이아웃
// - extension_point 타입은 프론트가 preview_layout 으로 전달
$previewLayout = $request->validated('preview_layout');
if (! $previewLayout) {
$previewLayout = $extension->extension_type->value === 'overlay'
? $extension->target_name
: null;
}
if (! $previewLayout) {
return $this->error('validation.layout_extension.preview_layout.required', 422);
}
try {
$preview = $this->layoutPreviewService->createExtensionPreview(
$extension->template_id,
$extensionId,
$previewLayout,
$request->validated('content'),
$request->user()->id
);
return $this->success('common.success', [
'token' => $preview->token,
'preview_url' => '/preview/'.$preview->token,
'expires_at' => $preview->expires_at->toIso8601String(),
]);
} catch (\Exception $e) {
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 템플릿 식별자와 확장 ID 로 확장을 조회하고, 템플릿 소속을 교차검증합니다.
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null (템플릿 미존재 / 확장 미존재 / 소속 불일치)
*/
private function resolveExtension(string $templateName, int $extensionId): ?LayoutExtension
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return null;
}
try {
$extension = $this->layoutExtensionService->getExtensionById($extensionId);
} catch (ModelNotFoundException) {
return null;
}
// 확장이 요청 템플릿에 속하는지 교차검증
if ($extension->template_id !== $template->id) {
return null;
}
return $extension;
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoBotPreviewRequest;
use App\Seo\Editor\SeoBotPreviewService;
use Illuminate\Http\JsonResponse;
/**
* 봇 HTML 실시간 미리보기 엔드포인트 — 편집기 [검색엔진] 탭.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(Bearer fetch). dirty 레이아웃
* + 편집기 샘플 데이터로 운영과 동일 코드 경로(`renderFromResolved`)를 거쳐 완성 HTML 을
* 반환한다(SEO 캐시 우회). 설정 변경 디바운스 후 재호출로 실시간 갱신된다.
*/
class SeoBotPreviewController extends AdminBaseController
{
public function __construct(
private readonly SeoBotPreviewService $previewService,
) {
parent::__construct();
}
/**
* dirty 레이아웃 + 샘플로 봇 HTML 미리보기를 반환합니다.
*
* @param SeoBotPreviewRequest $request dirty 레이아웃·샘플·route·locale 검증된 입력
* @param string $identifier 템플릿 식별자
* @return JsonResponse 완성 HTML 또는 SEO 미노출 안내
*/
public function show(SeoBotPreviewRequest $request, string $identifier): JsonResponse
{
$validated = $request->validated();
$html = $this->previewService->render(
$validated['layout'],
$validated['route_params'] ?? [],
$validated['url'] ?? '/',
$validated['locale'] ?? app()->getLocale(),
$identifier,
$validated['module_id'] ?? null,
$validated['plugin_id'] ?? null,
$validated['seed_context'] ?? [],
);
// JSON 직렬화 경계 — 미리보기 HTML 에 유효하지 않은 UTF-8 바이트가 있으면 JsonResponse 가
// "Malformed UTF-8" 로 500 을 던진다(샘플 데이터/표현식 평가 잔여로 잘린 멀티바이트 가능).
// 응답 직전 한 번 정화해 직렬화를 보장한다(산출물 내용 불변 — 깨진 바이트만 제거).
if (is_string($html)) {
$clean = @iconv('UTF-8', 'UTF-8//IGNORE', $html);
$html = $clean === false ? mb_convert_encoding($html, 'UTF-8', 'UTF-8') : $clean;
}
return $this->success('common.success', [
'identifier' => $identifier,
// null = meta.seo.enabled=false 또는 미렌더 → 편집기 "검색엔진 미노출" 안내.
'enabled' => $html !== null,
'html' => $html,
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCandidateRequest;
use App\Seo\Editor\SeoCandidateService;
use Illuminate\Http\JsonResponse;
/**
* SEO 후보 엔드포인트 — 레이아웃 편집기 [검색엔진] 탭 후보 공급.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출된다(권한 후보 엔드포인트와
* 동일 패턴 — admin 전역 G7Config broadcast 회피, Bearer fetch). page_type 후보·toggle_setting
* 후보·유효 vars 후보를 한 번에 공급한다. 후보 미존재 시 빈 목록 → 편집기 자유 텍스트 폴백.
*
* 입력(query): `extensions`(JSON `[{type,id}]`), `page_type`(string|null).
*/
class SeoCandidateController extends AdminBaseController
{
public function __construct(
private readonly SeoCandidateService $candidateService,
) {
parent::__construct();
}
/**
* SEO 후보를 수집해 반환합니다.
*
* @param SeoCandidateRequest $request 편집 중 레이아웃의 extensions/page_type 을 query 로 전달
* @param string $identifier 템플릿 식별자(라우트 일관성 — 후보는 활성 확장 기준)
* @return JsonResponse page_types / toggle_settings / vars / extensions 후보 응답
*/
public function index(SeoCandidateRequest $request, string $identifier): JsonResponse
{
$extensions = $this->parseExtensions($request->query('extensions'));
$pageType = $request->query('page_type');
$pageType = is_string($pageType) && $pageType !== '' ? $pageType : null;
$candidates = $this->candidateService->collect(
$extensions,
$pageType,
app()->getLocale(),
);
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $candidates),
);
}
/**
* extensions query 파라미터를 `[{type,id}]` 배열로 파싱합니다.
*
* JSON 문자열 또는 배열 모두 허용(잘못된 형태는 빈 배열로 폴백 — 가드는 라우트 미들웨어).
*
* @param mixed $raw query('extensions')
* @return array<int, array{type: string, id: string}>
*/
private function parseExtensions(mixed $raw): array
{
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (! is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $ext) {
if (is_array($ext) && isset($ext['type'], $ext['id'])
&& is_string($ext['type']) && is_string($ext['id'])) {
$out[] = ['type' => $ext['type'], 'id' => $ext['id']];
}
}
return $out;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoOgPreviewRequest;
use App\Seo\Editor\SeoOgPreviewService;
use Illuminate\Http\JsonResponse;
/**
* OG/Twitter/구조화 미리보기 엔드포인트 — 편집기 [검색엔진] 탭.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(Bearer fetch). dirty meta.seo +
* 샘플로 og/twitter cascade 를 실제 계산하고 필터 전/후 diff 로 출처·잠김을 산출한다. 탭 진입 /
* page_type·extensions 변경 시 재호출(이전 기본값 무효화).
*/
class SeoOgPreviewController extends AdminBaseController
{
public function __construct(
private readonly SeoOgPreviewService $previewService,
) {
parent::__construct();
}
/**
* og/twitter/structured 미리보기를 반환합니다.
*
* @param SeoOgPreviewRequest $request dirty meta.seo·샘플·route 검증된 입력
* @param string $identifier 템플릿 식별자
* @return JsonResponse 키별 cascade(값/출처/override/필터잠김) + structured 미리보기
*/
public function show(SeoOgPreviewRequest $request, string $identifier): JsonResponse
{
$validated = $request->validated();
$preview = $this->previewService->preview(
$validated['seo'],
$validated['seed_context'] ?? [],
$validated['route_params'] ?? [],
$validated['own_seo'] ?? null,
app()->getLocale(),
);
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $preview),
);
}
}
@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\TemplateCustomTranslation\BulkDestroyCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\IndexCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\StoreCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\UpdateCustomTranslationRequest;
use App\Http\Resources\TemplateCustomTranslationResource;
use App\Services\TemplateCustomTranslationService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 템플릿 커스텀 다국어 키 컨트롤러.
*
* 레이아웃 편집기 인라인 편집/번역 탭에서 동적 다국어 키를
* CRUD 합니다. 권한은 `core.templates.layouts.edit` 미들웨어에서 처리합니다.
*/
class TemplateCustomTranslationController extends AdminBaseController
{
/**
* @param TemplateCustomTranslationService $service 커스텀 다국어 키 서비스
* @param TemplateService $templateService 템플릿 서비스 (식별자 → ID 변환)
*/
public function __construct(
private readonly TemplateCustomTranslationService $service,
private readonly TemplateService $templateService,
) {
parent::__construct();
}
/**
* 커스텀 다국어 키 목록 조회
*
* @param IndexCustomTranslationRequest $request 목록 조회 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 커스텀 키 목록 응답
*/
public function index(IndexCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$list = $this->service->getList(
$template->id,
$request->input('layout_name'),
$request->input('status'),
);
return $this->success(
'common.success',
TemplateCustomTranslationResource::collection($list),
);
}
/**
* 커스텀 다국어 키 생성 (인라인 편집 확정)
*
* @param StoreCustomTranslationRequest $request 생성 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 생성된 커스텀 키 응답
*/
public function store(StoreCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$model = $this->service->createKey(
templateId: $template->id,
layoutName: $request->input('layout_name'),
locale: $request->input('locale'),
value: $request->input('value'),
createdBy: $this->getCurrentUser()?->id,
);
DB::commit();
return $this->success(
'common.success',
new TemplateCustomTranslationResource($model),
201,
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 수정 (번역 탭 일괄 편집, 낙관적 잠금)
*
* @param UpdateCustomTranslationRequest $request 수정 요청
* @param string $templateName 템플릿 identifier
* @param int $id 커스텀 키 ID
* @return JsonResponse 수정된 커스텀 키 응답 (충돌 시 409)
*/
public function update(UpdateCustomTranslationRequest $request, string $templateName, int $id): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$model = $this->service->find($id);
if ($model === null || (int) $model->template_id !== (int) $template->id) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$updated = $this->service->updateValues(
id: $id,
values: $request->input('values'),
expectedLockVersion: (int) $request->input('expected_lock_version'),
updatedBy: $this->getCurrentUser()?->id,
);
DB::commit();
return $this->success(
'common.success',
new TemplateCustomTranslationResource($updated),
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (ModelNotFoundException $e) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 삭제
*
* @param string $templateName 템플릿 identifier
* @param int $id 커스텀 키 ID
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(string $templateName, int $id): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$model = $this->service->find($id);
if ($model === null || (int) $model->template_id !== (int) $template->id) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$this->service->deleteKey($id);
DB::commit();
return $this->success('common.success');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 일괄 삭제 (관리 모달 "선택 삭제"/"미사용 전체 삭제")
*
* 요청 ids 중 해당 템플릿 소속 키만 삭제합니다(교차 템플릿 삭제 차단).
*
* @param BulkDestroyCustomTranslationRequest $request 일괄 삭제 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 삭제 결과 응답 (삭제 건수 포함)
*/
public function bulkDestroy(BulkDestroyCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
// 요청 ids 중 해당 템플릿 소속만 추려 교차 템플릿 삭제를 차단.
$ownedIds = [];
foreach ((array) $request->input('ids', []) as $id) {
$model = $this->service->find((int) $id);
if ($model !== null && (int) $model->template_id === (int) $template->id) {
$ownedIds[] = (int) $id;
}
}
if ($ownedIds === []) {
return $this->success('common.success', ['deleted' => 0]);
}
try {
DB::beginTransaction();
$deleted = $this->service->deleteKeys($ownedIds);
DB::commit();
return $this->success('common.success', ['deleted' => $deleted]);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
}
@@ -36,7 +36,7 @@ class UserController extends AdminBaseController
/**
* 필터링된 사용자 목록을 조회합니다.
*
* @param UserListRequest $request 사용자 목록 요청 데이터
* @param UserListRequest $request 사용자 목록 요청 데이터
* @return JsonResponse 사용자 목록과 통계 정보를 포함한 JSON 응답
*/
public function index(UserListRequest $request): JsonResponse
@@ -61,7 +61,7 @@ class UserController extends AdminBaseController
/**
* 새로운 사용자를 생성합니다.
*
* @param CreateUserRequest $request 사용자 생성 요청 데이터
* @param CreateUserRequest $request 사용자 생성 요청 데이터
* @return JsonResponse 생성된 사용자 정보를 포함한 JSON 응답
*/
public function store(CreateUserRequest $request): JsonResponse
@@ -84,7 +84,7 @@ class UserController extends AdminBaseController
/**
* 특정 사용자의 상세 정보를 조회합니다.
*
* @param User $user 조회할 사용자 모델
* @param User $user 조회할 사용자 모델
* @return JsonResponse 사용자 상세 정보를 포함한 JSON 응답
*/
public function show(User $user): JsonResponse
@@ -109,8 +109,8 @@ class UserController extends AdminBaseController
/**
* 기존 사용자 정보를 수정합니다.
*
* @param UpdateUserRequest $request 사용자 수정 요청 데이터
* @param User $user 수정할 사용자 모델
* @param UpdateUserRequest $request 사용자 수정 요청 데이터
* @param User $user 수정할 사용자 모델
* @return JsonResponse 수정된 사용자 정보를 포함한 JSON 응답
*/
public function update(UpdateUserRequest $request, User $user): JsonResponse
@@ -132,8 +132,8 @@ class UserController extends AdminBaseController
/**
* 사용자를 삭제합니다.
*
* @param DeleteUserRequest $request 사용자 삭제 요청 데이터
* @param User $user 삭제할 사용자 모델
* @param DeleteUserRequest $request 사용자 삭제 요청 데이터
* @param User $user 삭제할 사용자 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(DeleteUserRequest $request, User $user): JsonResponse
@@ -149,7 +149,13 @@ class UserController extends AdminBaseController
} catch (CannotDeleteSuperAdminException $e) {
return $this->error('exceptions.cannot_delete_super_admin', 422);
} catch (ValidationException $e) {
return $this->error('user.delete_failed', 422, $e->errors());
// UserService 가 던진 ValidationException 의 general[0] 에는 이미 `:error` 가
// 치환된 완성 메시지(예: "사용자 삭제에 실패했습니다: <상세 사유>")가 들어있다.
// 이를 최상위 message 로도 노출해 토스트에 `:error` 가 그대로 보이지 않게 한다
// (에러 상세 표시 기능 유지). errors 배열도 함께 전달.
$detail = $e->errors()['general'][0] ?? null;
return $this->error($detail ?? 'user.delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.delete_failed', 500, $e, ['error' => $e->getMessage()]);
}
@@ -198,7 +204,7 @@ class UserController extends AdminBaseController
/**
* 키워드로 사용자를 검색합니다. (이름, 닉네임, 이메일)
*
* @param SearchUserRequest $request 사용자 검색 요청 데이터
* @param SearchUserRequest $request 사용자 검색 요청 데이터
* @return JsonResponse 검색된 사용자 목록을 포함한 JSON 응답
*/
public function search(SearchUserRequest $request): JsonResponse
@@ -228,7 +234,7 @@ class UserController extends AdminBaseController
/**
* 이메일 주소의 중복 여부를 확인합니다.
*
* @param CheckEmailRequest $request 이메일 중복 확인 요청 데이터
* @param CheckEmailRequest $request 이메일 중복 확인 요청 데이터
* @return JsonResponse 이메일 사용 가능 여부를 포함한 JSON 응답
*/
public function checkEmail(CheckEmailRequest $request): JsonResponse
@@ -251,6 +257,9 @@ class UserController extends AdminBaseController
/**
* 현재 로그인된 사용자의 언어 설정을 업데이트합니다.
*
* @param UpdateLanguageRequest $request 언어 변경 요청 데이터
* @return JsonResponse 변경된 사용자 정보를 포함한 JSON 응답
*/
public function updateMyLanguage(UpdateLanguageRequest $request): JsonResponse
{
@@ -274,6 +283,9 @@ class UserController extends AdminBaseController
/**
* 여러 사용자의 상태를 일괄 변경합니다.
*
* @param BulkUpdateUserStatusRequest $request 일괄 상태 변경 요청 데이터
* @return JsonResponse 일괄 변경 결과를 포함한 JSON 응답
*/
public function bulkUpdateStatus(BulkUpdateUserStatusRequest $request): JsonResponse
{
@@ -2,13 +2,13 @@
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
@@ -36,7 +36,7 @@ class AuthController extends AuthBaseController
/**
* 사용자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
@@ -64,7 +64,7 @@ class AuthController extends AuthBaseController
/**
* 새로운 사용자를 등록시킵니다.
*
* @param RegisterRequest $request 등록 요청 데이터
* @param RegisterRequest $request 등록 요청 데이터
* @return JsonResponse 등록 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function register(RegisterRequest $request): JsonResponse
@@ -84,7 +84,7 @@ class AuthController extends AuthBaseController
/**
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
@@ -97,7 +97,7 @@ class AuthController extends AuthBaseController
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logoutFromAllDevices(Request $request): JsonResponse
@@ -110,7 +110,7 @@ class AuthController extends AuthBaseController
/**
* 현재 로그인된 사용자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
@@ -120,16 +120,18 @@ class AuthController extends AuthBaseController
// 역할 관계 로드 (권한은 역할을 통해 간접 연결)
$user->load(['roles.permissions']);
return $this->successWithResource(
// toAuthArray(): core.user.filter_resource_data 필터를 적용해 모듈 필드(결제 통화 등)를 병합.
// 프론트 currentUser 출처라, 로그인 시 계정 영속 통화 덮어씀(D-LOGIN-CUR)을 충족한다.
return $this->success(
'common.success',
new UserResource($user)
(new UserResource($user))->toAuthArray($request)
);
}
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
@@ -147,7 +149,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호 재설정 토큰을 검증합니다.
*
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @return JsonResponse 토큰 유효성 검증 결과 JSON 응답
*/
public function validateResetToken(ValidateResetTokenRequest $request): JsonResponse
@@ -171,7 +173,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호 찾기 요청을 처리하고 인증 이메일을 발송합니다.
*
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @return JsonResponse 이메일 발송 결과 JSON 응답
*/
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
@@ -192,7 +194,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호를 재설정합니다.
*
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @return JsonResponse 비밀번호 재설정 결과 JSON 응답
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
@@ -103,11 +103,19 @@ class IdentityVerificationController extends PublicBaseController
],
);
// verify 실패 시에도 서버 측 시도 횟수를 응답에 포함 — 클라이언트가 자체 카운트를 서버와 동기화하여
// "남은 시도 횟수" UI 가 다른 탭/세션과 불일치하지 않도록.
if (! $result->success) {
$fresh = $challenge->fresh();
return $this->error(
$result->failureReason ?: 'identity.errors.generic',
422,
['failure_code' => $result->failureCode],
[
'failure_code' => $result->failureCode,
'attempts' => $fresh ? (int) $fresh->attempts : (int) $challenge->attempts,
'max_attempts' => $fresh ? (int) $fresh->max_attempts : (int) $challenge->max_attempts,
],
);
}
@@ -59,14 +59,45 @@ class PublicLayoutController extends PublicBaseController
}
try {
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
//
// 서버 캐시 키에는 **정수 버전만** 쓴다(소수 nonce 제거). 레이아웃 편집기는 같은 세션의
// 저장·버전 복원 직후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}` 형식으로
// 요청한다(클라이언트 cache-bust nonce). 그런데 `serve` 가 이 문자열을 그대로 캐시 키에
// 쓰면 키가 `...v{cacheVersion}.{nonce}.meta` 가 되는데, 저장 경로
// `LayoutService::clearPublicServingCache` 는 `(int) ext.cache_version` 으로 nonce 없는
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
$cacheVersion = (int) request()->query('v', 0);
// 서버 측 캐싱 (1시간 유효)
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
// - 일반 사이트 렌더는 옵션을 전달하지 않으므로 응답 형식 종전과 100% 동일
$withSourceMeta = (bool) request()->query('with_source_meta', false);
// 출처 메타 요청은 편집 권한 필요 — 일반 사용자가 메타를 보면 안 됨
// @since engine-v1.50.0
if ($withSourceMeta) {
$user = request()->user();
if ($user === null) {
return $this->unauthorized('auth.layout_guest_permission_denied', [
'required_permissions' => 'core.templates.layouts.edit',
]);
}
if (! PermissionHelper::check('core.templates.layouts.edit', $user)) {
return $this->forbidden('auth.layout_permission_denied', [
'required_permissions' => 'core.templates.layouts.edit',
]);
}
}
// 서버 측 캐싱 (1시간 유효) — 메타 포함/미포함은 별도 캐시 키
// getLayout()을 사용하여 레이아웃 로드, 병합, 확장 적용을 한 번에 수행
$metaSuffix = $withSourceMeta ? '.meta' : '';
$mergedLayout = $this->cached(
"layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}",
fn () => $this->layoutService->getLayout($templateIdentifier, $layoutName),
"layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}{$metaSuffix}",
fn () => $this->layoutService->getLayout($templateIdentifier, $layoutName, $withSourceMeta),
self::CACHE_TTL
);
@@ -28,7 +28,7 @@ class PublicModuleController extends PublicBaseController
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @param string $path 에셋 경로 (dist/js/module.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServeModuleAssetRequest $request,
@@ -55,4 +55,57 @@ class PublicModuleController extends PublicBaseController
// 파일 반환 (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);
}
}
@@ -28,7 +28,7 @@ class PublicPluginController extends PublicBaseController
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @param string $path 에셋 경로 (dist/js/plugin.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServePluginAssetRequest $request,
@@ -55,4 +55,57 @@ class PublicPluginController extends PublicBaseController
// 파일 반환 (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 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$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);
}
}
@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,7 +20,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicTemplateController extends PublicBaseController
{
public function __construct(
private TemplateService $templateService
private TemplateService $templateService,
private TemplateLayoutAttachmentService $layoutAttachmentService,
) {
parent::__construct();
}
@@ -26,6 +30,7 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
* @return JsonResponse 라우트 정보 응답
*/
public function getRoutes(string $identifier): JsonResponse
{
@@ -82,6 +87,11 @@ class PublicTemplateController extends PublicBaseController
/**
* 템플릿 정적 파일 서빙
*
* @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증)
* @param string $identifier 템플릿 식별자
* @param string $path 요청 경로
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러
*/
public function serveAsset(ServeTemplateAssetRequest $request, string $identifier, string $path): BinaryFileResponse|JsonResponse|Response
{
@@ -110,6 +120,7 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
@@ -140,6 +151,7 @@ class PublicTemplateController extends PublicBaseController
* error_config 등 템플릿 메타데이터를 프론트엔드에 제공합니다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 템플릿 설정 응답
*/
public function serveConfig(string $identifier): JsonResponse
{
@@ -207,6 +219,7 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
* @return JsonResponse 다국어 데이터 응답
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
{
@@ -262,4 +275,68 @@ class PublicTemplateController extends PublicBaseController
// 성공 응답 (JSON 데이터 직접 반환, 래핑 없음)
return $this->cachedJsonResponse($languageData['data'], 3600);
}
/**
* 편집기 스펙 조회 — 템플릿 editor-spec.json 파일 반환
*
* Phase 3 S5a-1 에서 `nesting` 블록이 추가되었다. 본 엔드포인트는
* 활성 디렉토리 → _bundled 폴백 순으로 editor-spec.json 을 읽어 반환한다.
* 파일 미존재 시 spec=null 로 폴백 (편집기는 spec 미제공 안내).
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('templates.editor_spec', ['identifier' => $identifier]);
// 분할 editor-spec.json 은 manifest + `$include` 블록으로 구성된다.
// 활성 디렉토리만 기준으로 합본한 단일 spec 을 반환한다(_bundled 폴백 없음).
// 합본 결과는 분할 전 단일 파일과 동일 형태(프론트엔드 로더 무영향).
// 미분할 파일은 원본 반환(하위 호환), 미존재 시 null.
$spec = EditorSpecAssembler::assemble(
base_path("templates/{$identifier}/editor-spec.json")
);
$message = $spec === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success(
$message,
[
'identifier' => $identifier,
'spec' => $spec,
]
);
}
/**
* 레이아웃 첨부 이미지 파일 서빙.
*
* 발행된 배경 이미지는 일반 사이트 방문자에게도 로드되어야 하므로 인증 불필요한
* 공개 엔드포인트로 둔다. 첨부는 비공개 `attachments` 디스크에 저장되므로 직접
* 공개 URL 이 없어, 본 라우트가 파일을 캐싱 헤더와 함께 인라인 스트림한다.
* 첨부가 경로의 템플릿 소속이 아니거나 파일이 없으면 404.
*
* @param string $identifier 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
* @return BinaryFileResponse|Response|JsonResponse 파일 응답 또는 404
*/
public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): BinaryFileResponse|Response|JsonResponse
{
$filePath = $this->layoutAttachmentService->getServableFilePath($identifier, $attachment);
if ($filePath === null) {
return $this->notFound('templates.layout_attachments.errors.not_found');
}
// 이미지/일반 파일 모두 캐싱 헤더와 함께 인라인 응답 (레이아웃 캐시 TTL, 기본 24시간).
// PublicAttachmentController 의 이미지 서빙과 동일한 fileResponse(ETag/Cache-Control) 사용.
return $this->fileResponse(
$filePath,
$attachment->mime_type,
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
}