Files
Gnuboard7/app/Http/Controllers/Api/Admin/TemplateController.php
T
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00

574 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Exceptions\TemplateOperationException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Template\ActivateTemplateRequest;
use App\Http\Requests\Template\DeactivateTemplateRequest;
use App\Http\Requests\Template\IndexTemplateRequest;
use App\Http\Requests\Template\InstallTemplateFromFileRequest;
use App\Http\Requests\Template\InstallTemplateFromGithubRequest;
use App\Http\Requests\Template\InstallTemplateRequest;
use App\Http\Requests\Template\PerformTemplateUpdateRequest;
use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\RefreshTemplateLayoutsRequest;
use App\Http\Requests\Template\UninstallTemplateRequest;
use App\Http\Resources\TemplateCollection;
use App\Http\Resources\TemplateResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 템플릿 관리 컨트롤러
*
* 관리자가 시스템 템플릿을 설치, 활성화, 비활성화, 제거할 수 있는 기능을 제공합니다.
*/
class TemplateController extends AdminBaseController
{
use InjectsExtensionLanguagePacks;
use OrchestratesCascadeInstall;
public function __construct(
private TemplateService $templateService,
private LicenseService $licenseService
) {
parent::__construct();
}
/**
* 모든 템플릿 목록을 조회합니다 (설치된 템플릿과 미설치 템플릿 포함).
*
* 페이지네이션 및 다중 검색 조건을 지원합니다.
* - search: 단일 검색어 (이름, 식별자, 설명, 벤더 OR 검색)
* - filters: 다중 검색 조건 (AND 조건)
*
* @param IndexTemplateRequest $request 템플릿 목록 조회 요청
* @return JsonResponse 템플릿 목록을 포함한 JSON 응답
*/
public function index(IndexTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$filters = [
'search' => $validated['search'] ?? null,
'filters' => $validated['filters'] ?? [],
'status' => $validated['status'] ?? null,
'type' => $validated['type'] ?? null,
'include_hidden' => (bool) ($validated['include_hidden'] ?? false),
];
$perPage = (int) ($validated['per_page'] ?? 12);
$page = (int) ($validated['page'] ?? 1);
$result = $this->templateService->getPaginatedTemplates($filters, $perPage, $page);
$collection = new TemplateCollection(collect($result['data']));
return $this->success('templates.fetch_success', [
'data' => $collection->toArray($request)['data'],
'pagination' => [
'total' => $result['total'],
'current_page' => $result['current_page'],
'last_page' => $result['last_page'],
'per_page' => $result['per_page'],
],
'meta' => $collection->with($request)['meta'],
'abilities' => [
'can_install' => PermissionHelper::check('core.templates.install', $request->user()),
'can_activate' => PermissionHelper::check('core.templates.activate', $request->user()),
'can_uninstall' => PermissionHelper::check('core.templates.uninstall', $request->user()),
],
]);
} catch (\Exception $e) {
return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 템플릿의 상세 정보를 조회합니다.
*
* @param Request $request HTTP 요청 (attachLanguagePacks 의 Request 인자 전달용)
* @param string $templateName 템플릿 식별자
* @return JsonResponse 템플릿 정보를 포함한 JSON 응답
*/
// audit:allow controller-base-request-injection reason: GET 상세 조회. attachLanguagePacks($detail, scope, name, $request) 전달용
public function show(Request $request, string $templateName): JsonResponse
{
try {
$templateInfo = $this->templateService->getTemplateInfo($templateName);
if (! $templateInfo) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
// 상세 정보는 toDetailArray() 메서드 사용 + 지원 언어팩 주입
$resource = new TemplateResource($templateInfo);
$detail = $this->attachLanguagePacks(
$resource->toDetailArray(),
LanguagePackScope::Template,
$templateName,
$request,
);
return $this->success('templates.fetch_success', $detail);
} catch (\Exception $e) {
return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿 설치 cascade 프리뷰를 반환합니다 (의존 확장 + 동반 가능 번들 언어팩).
*
* @param string $templateName 템플릿 식별자
* @param ExtensionInstallPreviewBuilder $builder 프리뷰 빌더
* @return JsonResponse cascade 프리뷰 응답
*/
public function installPreview(string $templateName, ExtensionInstallPreviewBuilder $builder): JsonResponse
{
try {
$preview = $builder->build(LanguagePackScope::Template, $templateName);
return $this->success('templates.fetch_success', $preview);
} catch (\Exception $e) {
return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿을 시스템에 설치합니다.
*
* @param InstallTemplateRequest $request 템플릿 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function install(InstallTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$templateName = $validated['template_name'];
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []);
$template = $this->templateService->installTemplate($templateName);
if ($template) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
$lpFailures = $this->installSelectedLanguagePacks($validated['language_packs'] ?? []);
$payload = (new TemplateResource($template))->toArray($request);
$payload['language_pack_failures'] = $lpFailures;
return $this->success('templates.install_success', $payload, 201);
} else {
return $this->error('templates.install_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.install_failed');
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('templates.errors.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
]);
}
}
/**
* 템플릿을 활성화합니다.
*
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
*
* @param ActivateTemplateRequest $request 템플릿 활성화 요청 데이터
* @return JsonResponse 활성화된 템플릿 정보를 포함한 JSON 응답
*/
public function activate(ActivateTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$templateName = $validated['template_name'];
$force = $validated['force'] ?? false;
$result = $this->templateService->activateTemplate($templateName, $force);
// 경고 응답인 경우 (필요 의존성 미충족) - 활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('templates.activate_warning', 409, [
'warning' => true,
'missing_modules' => $result['missing_modules'] ?? [],
'missing_plugins' => $result['missing_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$templateInfo = $result['template_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('template', $templateName);
if ($templateInfo) {
return $this->success('templates.activate_success', [
'template' => (new TemplateResource($templateInfo))->resolve(),
'pending_language_packs' => $pendingLanguagePacks,
]);
}
return $this->success('templates.activate_success', array_merge($result, [
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
return $this->error('templates.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿을 비활성화합니다.
*
* @param DeactivateTemplateRequest $request 템플릿 비활성화 요청 데이터
* @return JsonResponse 비활성화된 템플릿 정보를 포함한 JSON 응답
*/
public function deactivate(DeactivateTemplateRequest $request): JsonResponse
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason);
if ($template) {
return $this->successWithResource(
'templates.deactivate_success',
new TemplateResource($template)
);
} else {
return $this->error('templates.deactivate_failed', 400, null, [
'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿을 시스템에서 제거합니다.
*
* @param UninstallTemplateRequest $request 템플릿 제거 요청 데이터
* @return JsonResponse 제거 결과 JSON 응답
*/
public function uninstall(UninstallTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$templateName = $validated['template_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->templateService->uninstallTemplate($templateName, $deleteData, $preservedBackups);
if ($result) {
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('templates.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('templates.uninstall_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿 삭제 시 삭제될 데이터 정보를 조회합니다.
*
* @param string $templateName 템플릿명
* @return JsonResponse 삭제 정보를 포함한 JSON 응답
*/
public function uninstallInfo(string $templateName): JsonResponse
{
try {
$uninstallInfo = $this->templateService->getTemplateUninstallInfo($templateName);
if (! $uninstallInfo) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
return $this->success('templates.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
return $this->error('templates.uninstall_info_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
*
* @param PreviewTemplateManifestRequest $request 미리보기 요청
* @return JsonResponse manifest + validation 결과
*/
public function manifestPreview(PreviewTemplateManifestRequest $request): JsonResponse
{
try {
$result = $this->templateService->previewManifest($request->file('file'));
return $this->success('templates.preview_success', $result);
} catch (\Throwable $e) {
return $this->error('templates.preview_failed', 422, null, ['error' => $e->getMessage()]);
}
}
/**
* ZIP 파일에서 템플릿을 설치합니다.
*
* @param InstallTemplateFromFileRequest $request 파일 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function installFromFile(InstallTemplateFromFileRequest $request): JsonResponse
{
try {
$file = $request->file('file');
$template = $this->templateService->installFromZipFile($file);
return $this->successWithResource(
'templates.install_success',
new TemplateResource($template),
201
);
} catch (TemplateOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* GitHub 저장소에서 템플릿을 설치합니다.
*
* @param InstallTemplateFromGithubRequest $request GitHub 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function installFromGithub(InstallTemplateFromGithubRequest $request): JsonResponse
{
try {
$githubUrl = $request->validated()['github_url'];
$template = $this->templateService->installFromGithub($githubUrl);
return $this->successWithResource(
'templates.install_success',
new TemplateResource($template),
201
);
} catch (TemplateOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 템플릿의 레이아웃을 파일에서 다시 읽어 갱신합니다.
*
* @param RefreshTemplateLayoutsRequest $request 레이아웃 갱신 요청 데이터
* @return JsonResponse 갱신된 템플릿 정보를 포함한 JSON 응답
*/
public function refreshLayouts(RefreshTemplateLayoutsRequest $request): JsonResponse
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->refreshTemplateLayouts($templateName);
if ($template) {
return $this->successWithResource(
'templates.refresh_layouts_success',
new TemplateResource($template)
);
} else {
return $this->error('templates.refresh_layouts_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 설치된 모든 템플릿의 업데이트를 확인합니다.
*
* @return JsonResponse 업데이트 확인 결과 JSON 응답
*/
public function checkUpdates(): JsonResponse
{
try {
$result = $this->templateService->checkForUpdates();
return $this->success('templates.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 템플릿의 수정된 레이아웃을 확인합니다.
*
* 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인하여
* 레이아웃 전략(overwrite/keep) 선택에 참고할 수 있도록 합니다.
*
* @param string $templateName 템플릿 식별자
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
*/
public function checkModifiedLayouts(string $templateName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 템플릿 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고된다.
if (! $this->templateService->getTemplateInfo($templateName)) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
$result = $this->templateService->checkModifiedLayouts($templateName);
return $this->success('templates.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 템플릿을 업데이트합니다.
*
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
* - overwrite: 모든 레이아웃을 새 버전으로 교체
* - keep: 사용자가 수정한 레이아웃을 유지
*
* @param PerformTemplateUpdateRequest $request 업데이트 요청 데이터
* @param string $templateName 업데이트할 템플릿 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(PerformTemplateUpdateRequest $request, string $templateName): JsonResponse
{
try {
$validated = $request->validated();
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
$force = (bool) ($validated['force'] ?? false);
$result = $this->templateService->performVersionUpdate($templateName, $layoutStrategy, $force);
$templateInfo = $result['template_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":template"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'template' => $templateName,
'version' => $result['to_version'] ?? ($templateInfo['version'] ?? ''),
];
if ($templateInfo) {
return $this->successWithResource(
'templates.update_success',
new TemplateResource($templateInfo),
200,
$messageParams
);
}
return $this->success('templates.update_success', $result, 200, $messageParams);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.errors.update_failed', ['template' => $templateName, 'error' => '']);
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('templates.errors.update_failed', 500, $e->getMessage(), [
'template' => $templateName,
'error' => $e->getMessage(),
]);
}
}
/**
* 특정 템플릿의 변경 내역(changelog)을 조회합니다.
*
* @param Request $request HTTP 요청
* @param string $identifier 템플릿 식별자
* @return JsonResponse 변경 내역을 포함한 JSON 응답
*/
public function changelog(ChangelogRequest $request, string $identifier): JsonResponse
{
try {
$validated = $request->validated();
$changelog = $this->templateService->getTemplateChangelog(
$identifier,
$validated['source'] ?? null,
$validated['from_version'] ?? null,
$validated['to_version'] ?? null,
);
return $this->success('template.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
return $this->error('template.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 템플릿의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
{
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $identifier)) {
return $this->error('templates.license_not_found', 404);
}
$content = $this->licenseService->getExtensionLicense('templates', $identifier);
if ($content === null) {
return $this->error('templates.license_not_found', 404);
}
return $this->success('templates.fetch_success', [
'content' => $content,
]);
}
}