- pay_nhnkcp 1.0.3 분리: 관리자 API 권한 게이트 변경분이 이미 배포된 1.0.2 섹션에 소급 누적돼 있어 배포본과 어긋나고 업데이트 감지도 불가하던 것을 manifest/package/composer/lock 동기와 함께 정정 - 공개 시나리오 매니페스트의 내부 경로 참조 제거 — 릴리즈 파일 누출 검사가 error 로 발행을 차단하던 상태 해소 (내부 정적 검사 연계는 도구 비귀속 서술로 이관) - 모듈/플러그인/템플릿 수동 설치에서 깨진 zip·manifest 누락 같은 사용자 입력 오류가 500 으로 위장되던 422 계약 회귀 복원 — 서비스 경계에서 도메인 예외로 승격해 상태코드와 실패 사유를 함께 보존 (회귀 테스트 동반) - 본인인증 정책 검색 버튼이 입력 중인 검색어를 반영하지 않고, 초기화 후에도 입력창에 이전 검색어가 남던 문제 수정 — 폼 자동바인딩 옵트아웃 + 명시 상태 전환, E2E 회귀 스펙과 트러블슈팅 사례 동반 - 프록시 URL 검증 비문자열 분기의 :schemes 치환 누락 보정 (회귀 테스트 동반) - 7.0.8 사이클 출시일자를 2026-08-22 로 정렬 (코어·엔진·확장 7종·언어팩 3종, 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
1021 lines
38 KiB
PHP
1021 lines
38 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Extension\PluginManagerInterface;
|
|
use App\Contracts\Repositories\PluginRepositoryInterface;
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Exceptions\PluginOperationException;
|
|
use App\Extension\Helpers\ChangelogParser;
|
|
use App\Extension\Helpers\EditorSpecAssembler;
|
|
use App\Extension\Helpers\GithubHelper;
|
|
use App\Extension\Helpers\ZipInstallHelper;
|
|
use App\Extension\HookManager;
|
|
use App\Extension\Vendor\VendorMode;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class PluginService
|
|
{
|
|
/**
|
|
* 검색 가능한 필드 목록
|
|
*/
|
|
private const SEARCHABLE_FIELDS = ['name', 'identifier', 'description', 'vendor'];
|
|
|
|
public function __construct(
|
|
private PluginRepositoryInterface $pluginRepository,
|
|
private PluginManagerInterface $pluginManager
|
|
) {}
|
|
|
|
/**
|
|
* 활성화된 플러그인들의 identifier 목록을 반환합니다.
|
|
*
|
|
* @return array 활성화된 플러그인 identifier 배열
|
|
*/
|
|
public function getActivePluginIdentifiers(): array
|
|
{
|
|
return $this->pluginRepository->getActivePluginIdentifiers();
|
|
}
|
|
|
|
/**
|
|
* 모든 플러그인 목록을 조회합니다 (설치된 플러그인과 미설치 플러그인 포함).
|
|
*
|
|
* @return array 모든 플러그인 목록
|
|
*/
|
|
public function getAllPlugins(): array
|
|
{
|
|
// PluginManager 초기화
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
// 설치된 플러그인과 미설치 플러그인을 분리하여 반환
|
|
$installedPlugins = $this->pluginManager->getInstalledPluginsWithDetails();
|
|
$uninstalledPlugins = $this->pluginManager->getUninstalledPlugins();
|
|
|
|
return [
|
|
'installed' => array_values($installedPlugins),
|
|
'uninstalled' => array_values($uninstalledPlugins),
|
|
'total' => count($installedPlugins) + count($uninstalledPlugins),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 설치된 플러그인만 조회합니다.
|
|
*
|
|
* @return array 설치된 플러그인 목록
|
|
*/
|
|
public function getInstalledPluginsOnly(): array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return array_values($this->pluginManager->getInstalledPluginsWithDetails());
|
|
}
|
|
|
|
/**
|
|
* 미설치 플러그인만 조회합니다.
|
|
*
|
|
* @return array 미설치 플러그인 목록
|
|
*/
|
|
public function getUninstalledPluginsOnly(): array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return array_values($this->pluginManager->getUninstalledPlugins());
|
|
}
|
|
|
|
/**
|
|
* 특정 플러그인의 상세 정보를 조회합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return array|null 플러그인 정보 또는 null
|
|
*/
|
|
public function getPluginInfo(string $pluginName): ?array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return $this->pluginManager->getPluginInfo($pluginName);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 삭제 시 삭제될 데이터 정보를 조회합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return array|null 삭제 정보 (테이블 목록, 스토리지 디렉토리 목록, 용량) 또는 null
|
|
*/
|
|
public function getPluginUninstallInfo(string $pluginName): ?array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return $this->pluginManager->getPluginUninstallInfo($pluginName);
|
|
}
|
|
|
|
/**
|
|
* 플러그인을 설치합니다 (before/after_install 훅 발화 + ValidationException 변환).
|
|
*
|
|
* @param string $pluginName 플러그인 식별자
|
|
* @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled)
|
|
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
|
|
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
|
* @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null
|
|
*
|
|
* @throws ValidationException 플러그인 설치 실패 시
|
|
*/
|
|
public function installPlugin(
|
|
string $pluginName,
|
|
VendorMode $vendorMode = VendorMode::Auto,
|
|
bool $force = false,
|
|
?string &$failureReason = null,
|
|
): ?array {
|
|
$failureReason = null;
|
|
|
|
HookManager::doAction('core.plugins.before_install', $pluginName);
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force, $failureReason);
|
|
|
|
if ($result) {
|
|
// 설치 후 플러그인 정보 반환
|
|
$pluginInfo = $this->pluginManager->getPluginInfo($pluginName);
|
|
|
|
HookManager::doAction('core.plugins.after_install', $pluginName, $pluginInfo);
|
|
|
|
return $pluginInfo;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.installation_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인을 활성화합니다 (before/after_activate 훅 발화 + ValidationException 변환).
|
|
*
|
|
* 의존성 미충족 등으로 경고 응답이 돌아오면 success/warning 형태 그대로 반환합니다.
|
|
*
|
|
* @param string $pluginName 플러그인 식별자
|
|
* @param bool $force 의존성 경고를 무시하고 강제 활성화 여부
|
|
* @return array{success: bool, plugin_info?: array, warning?: bool} 활성화 결과
|
|
*
|
|
* @throws ValidationException 활성화 실패 시
|
|
*/
|
|
public function activatePlugin(string $pluginName, bool $force = false): array
|
|
{
|
|
HookManager::doAction('core.plugins.before_activate', $pluginName);
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->activatePlugin($pluginName, $force);
|
|
|
|
// 경고 응답인 경우 그대로 반환
|
|
if (isset($result['warning']) && $result['warning'] === true) {
|
|
return $result;
|
|
}
|
|
|
|
if ($result['success']) {
|
|
$pluginInfo = $this->pluginManager->getPluginInfo($pluginName);
|
|
|
|
HookManager::doAction('core.plugins.after_activate', $pluginName, $pluginInfo);
|
|
|
|
return [
|
|
'success' => true,
|
|
'plugin_info' => $pluginInfo,
|
|
];
|
|
}
|
|
|
|
// 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
|
|
// 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인을 비활성화합니다 (before/after_deactivate 훅 발화 + ValidationException 변환).
|
|
*
|
|
* 의존하는 다른 활성 확장이 있으면 경고 응답이 돌아오며 그대로 반환합니다.
|
|
*
|
|
* @param string $pluginName 플러그인 식별자
|
|
* @param bool $force 의존성 경고를 무시하고 강제 비활성화 여부
|
|
* @return array{success: bool, plugin_info?: array, warning?: bool} 비활성화 결과
|
|
*
|
|
* @throws ValidationException 비활성화 실패 시
|
|
*/
|
|
public function deactivatePlugin(string $pluginName, bool $force = false): array
|
|
{
|
|
HookManager::doAction('core.plugins.before_deactivate', $pluginName);
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->deactivatePlugin($pluginName, $force);
|
|
|
|
// 경고 응답인 경우 그대로 반환
|
|
if (isset($result['warning']) && $result['warning'] === true) {
|
|
return $result;
|
|
}
|
|
|
|
if ($result['success']) {
|
|
$pluginInfo = $this->pluginManager->getPluginInfo($pluginName);
|
|
|
|
// after_deactivate 훅은 PluginManager 가 string identifier 시그니처로 이미 발화
|
|
// (Service 중복 발화 제거 — listener 2회 실행으로 인한 activity log 중복 차단)
|
|
|
|
return array_merge($result, ['plugin_info' => $pluginInfo]);
|
|
}
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.deactivation_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인을 제거합니다 (before/after_uninstall 훅 발화).
|
|
*
|
|
* @param string $pluginName 플러그인 식별자
|
|
* @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부
|
|
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
|
* @return bool 제거 성공 여부
|
|
*
|
|
* @throws ValidationException 제거 실패 시
|
|
*/
|
|
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool
|
|
{
|
|
$failureReason = null;
|
|
|
|
HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData);
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
$result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason);
|
|
|
|
HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.uninstallation_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션 및 검색 필터가 적용된 플러그인 목록을 조회합니다.
|
|
*
|
|
* @param array $filters 검색 필터 (search, filters, status)
|
|
* @param int $perPage 페이지당 항목 수
|
|
* @param int $page 현재 페이지
|
|
* @return array 페이지네이션된 플러그인 목록
|
|
*/
|
|
public function getPaginatedPlugins(array $filters, int $perPage = 12, int $page = 1): array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
// 모든 플러그인 가져오기
|
|
$installedPlugins = $this->pluginManager->getInstalledPluginsWithDetails();
|
|
$uninstalledPlugins = $this->pluginManager->getUninstalledPlugins();
|
|
|
|
// 모든 플러그인 합치기
|
|
$allPlugins = array_merge(
|
|
array_values($installedPlugins),
|
|
array_values($uninstalledPlugins)
|
|
);
|
|
|
|
// 숨김 필터 적용 (기본: 숨김 항목 제외)
|
|
$allPlugins = $this->applyHiddenFilter($allPlugins, (bool) ($filters['include_hidden'] ?? false));
|
|
|
|
// 상태 필터 적용
|
|
if (! empty($filters['status'])) {
|
|
$allPlugins = $this->applyStatusFilter($allPlugins, $filters['status']);
|
|
}
|
|
|
|
// 다중 검색 필터 적용 (우선)
|
|
if (! empty($filters['filters']) && is_array($filters['filters'])) {
|
|
$allPlugins = $this->applyMultipleSearchFilters($allPlugins, $filters['filters']);
|
|
}
|
|
// 단일 검색어 필터 (하위 호환성)
|
|
elseif (! empty($filters['search'])) {
|
|
$allPlugins = $this->applyOrSearchAcrossFields($allPlugins, $filters['search']);
|
|
}
|
|
|
|
// 총 개수
|
|
$total = count($allPlugins);
|
|
|
|
// 페이지네이션 적용
|
|
$offset = ($page - 1) * $perPage;
|
|
$paginatedPlugins = array_slice($allPlugins, $offset, $perPage);
|
|
|
|
return [
|
|
'data' => array_values($paginatedPlugins),
|
|
'total' => $total,
|
|
'current_page' => $page,
|
|
'last_page' => (int) ceil($total / $perPage),
|
|
'per_page' => $perPage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 숨김 필터를 적용합니다.
|
|
*
|
|
* manifest 의 hidden=true 로 마킹된 플러그인은 기본 제외되며,
|
|
* $includeHidden=true 인 경우 포함됩니다.
|
|
*
|
|
* @param array $plugins 플러그인 목록
|
|
* @param bool $includeHidden 숨김 항목 포함 여부
|
|
* @return array 필터링된 플러그인 목록
|
|
*/
|
|
private function applyHiddenFilter(array $plugins, bool $includeHidden): array
|
|
{
|
|
if ($includeHidden) {
|
|
return $plugins;
|
|
}
|
|
|
|
return array_filter($plugins, function ($plugin) {
|
|
return empty($plugin['hidden']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 상태 필터를 적용합니다.
|
|
*
|
|
* @param array $plugins 플러그인 목록
|
|
* @param string $status 상태 (installed, not_installed, active, inactive)
|
|
* @return array 필터링된 플러그인 목록
|
|
*/
|
|
private function applyStatusFilter(array $plugins, string $status): array
|
|
{
|
|
return array_filter($plugins, function ($plugin) use ($status) {
|
|
return match ($status) {
|
|
'installed' => ($plugin['status'] ?? '') !== 'uninstalled',
|
|
'uninstalled' => ($plugin['status'] ?? '') === 'uninstalled',
|
|
'active' => ($plugin['status'] ?? '') === 'active',
|
|
'inactive' => ($plugin['status'] ?? '') === 'inactive',
|
|
default => true,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 다중 검색 조건을 적용합니다 (AND 조건).
|
|
*
|
|
* @param array $plugins 플러그인 목록
|
|
* @param array $searchFilters 검색 필터 배열
|
|
* @return array 필터링된 플러그인 목록
|
|
*/
|
|
private function applyMultipleSearchFilters(array $plugins, array $searchFilters): array
|
|
{
|
|
if (empty($searchFilters)) {
|
|
return $plugins;
|
|
}
|
|
|
|
return array_filter($plugins, function ($plugin) use ($searchFilters) {
|
|
foreach ($searchFilters as $filter) {
|
|
if (! $this->matchesFilter($plugin, $filter)) {
|
|
return false; // AND 조건: 하나라도 실패하면 제외
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 단일 필터 조건 매칭 여부를 확인합니다.
|
|
*
|
|
* @param array $plugin 플러그인 정보
|
|
* @param array $filter 필터 조건
|
|
* @return bool 매칭 여부
|
|
*/
|
|
private function matchesFilter(array $plugin, array $filter): bool
|
|
{
|
|
$field = $filter['field'] ?? null;
|
|
$value = $filter['value'] ?? null;
|
|
$operator = $filter['operator'] ?? 'like';
|
|
|
|
if (! $field || ! $value || ! in_array($field, self::SEARCHABLE_FIELDS)) {
|
|
return true; // 유효하지 않은 필터는 통과
|
|
}
|
|
|
|
// 필드 값 가져오기 (다국어 필드 처리)
|
|
$fieldValue = $this->getFieldValue($plugin, $field);
|
|
|
|
if ($fieldValue === null) {
|
|
return false;
|
|
}
|
|
|
|
return match ($operator) {
|
|
'eq' => mb_strtolower($fieldValue) === mb_strtolower($value),
|
|
'starts_with' => str_starts_with(mb_strtolower($fieldValue), mb_strtolower($value)),
|
|
'ends_with' => str_ends_with(mb_strtolower($fieldValue), mb_strtolower($value)),
|
|
default => str_contains(mb_strtolower($fieldValue), mb_strtolower($value)), // like
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 단일 검색어로 여러 필드를 OR 조건으로 검색합니다.
|
|
*
|
|
* @param array $plugins 플러그인 목록
|
|
* @param string $searchTerm 검색어
|
|
* @return array 필터링된 플러그인 목록
|
|
*/
|
|
private function applyOrSearchAcrossFields(array $plugins, string $searchTerm): array
|
|
{
|
|
$searchTerm = mb_strtolower($searchTerm);
|
|
|
|
return array_filter($plugins, function ($plugin) use ($searchTerm) {
|
|
foreach (self::SEARCHABLE_FIELDS as $field) {
|
|
$fieldValue = $this->getFieldValue($plugin, $field);
|
|
if ($fieldValue !== null && str_contains(mb_strtolower($fieldValue), $searchTerm)) {
|
|
return true; // OR 조건: 하나라도 매칭되면 포함
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 필드 값을 가져옵니다 (다국어 필드 처리 포함).
|
|
*
|
|
* @param array $plugin 플러그인 정보
|
|
* @param string $field 필드명
|
|
* @return string|null 필드 값
|
|
*/
|
|
private function getFieldValue(array $plugin, string $field): ?string
|
|
{
|
|
$value = $plugin[$field] ?? null;
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
// 다국어 필드인 경우 (name, description)
|
|
if (is_array($value)) {
|
|
// 현재 로케일 우선, 없으면 ko, 그 다음 en
|
|
$locale = app()->getLocale();
|
|
|
|
return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? reset($value) ?: null;
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
/**
|
|
* 설치된 플러그인들의 업데이트 가능 여부를 확인합니다.
|
|
*
|
|
* @return array 업데이트 확인 결과 (updated_count, details)
|
|
*
|
|
* @throws ValidationException 업데이트 확인 실패 시
|
|
*/
|
|
public function checkForUpdates(): array
|
|
{
|
|
HookManager::doAction('core.plugins.before_check_updates');
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->checkAllPluginsForUpdates();
|
|
|
|
HookManager::doAction('core.plugins.after_check_updates', $result);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugins' => [__('plugins.check_updates_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인을 업데이트합니다.
|
|
*
|
|
* @param string $pluginName 업데이트할 플러그인 identifier
|
|
* @param VendorMode $vendorMode Vendor 설치 모드
|
|
* @param string $layoutStrategy 레이아웃 전략 (overwrite|keep)
|
|
* @param bool $force 코어 버전 비호환 강제 우회 (위험 — 사용자 명시 필요)
|
|
* @return array 업데이트 결과 (identifier, from_version, to_version 등)
|
|
*
|
|
* @throws ValidationException 업데이트 실패 시
|
|
*/
|
|
public function updatePlugin(
|
|
string $pluginName,
|
|
VendorMode $vendorMode = VendorMode::Auto,
|
|
string $layoutStrategy = 'overwrite',
|
|
bool $force = false,
|
|
): array {
|
|
HookManager::doAction('core.plugins.before_update', $pluginName);
|
|
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->updatePlugin(
|
|
$pluginName,
|
|
$force,
|
|
null,
|
|
$vendorMode,
|
|
$layoutStrategy,
|
|
);
|
|
|
|
$pluginInfo = $this->pluginManager->getPluginInfo($pluginName);
|
|
|
|
HookManager::doAction('core.plugins.after_update', $pluginName, $result, $pluginInfo);
|
|
|
|
return array_merge($result, [
|
|
'plugin_info' => $pluginInfo,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
// Manager의 RuntimeException은 이미 번역된 메시지를 포함하므로
|
|
// getPrevious()로 원본 에러를 추출하여 이중 래핑 방지
|
|
$rawError = $e->getPrevious() ? $e->getPrevious()->getMessage() : $e->getMessage();
|
|
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.errors.update_failed', ['plugin' => $pluginName, 'error' => $rawError])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인의 수정된 레이아웃을 확인합니다.
|
|
*
|
|
* @param string $pluginName 확인할 플러그인 identifier
|
|
* @return array{has_modified_layouts: bool, modified_count: int, modified_layouts: array}
|
|
*
|
|
* @throws ValidationException 확인 실패 시
|
|
*/
|
|
public function checkModifiedLayouts(string $pluginName): array
|
|
{
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return $this->pluginManager->hasModifiedLayouts($pluginName);
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.check_modified_layouts_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
|
|
*
|
|
* @throws ValidationException 플러그인을 찾을 수 없거나 갱신 실패 시
|
|
*/
|
|
public function refreshPluginLayouts(string $pluginName): array
|
|
{
|
|
try {
|
|
$this->pluginManager->loadPlugins();
|
|
|
|
return $this->pluginManager->refreshPluginLayouts($pluginName);
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'plugin_name' => [__('plugins.refresh_layouts_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 에셋 파일 경로를 반환합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @param string $path 파일 경로
|
|
* @return array{success: bool, filePath: string|null, mimeType: string|null, error: string|null}
|
|
*/
|
|
public function getAssetFilePath(string $identifier, string $path): array
|
|
{
|
|
// 1. 활성화된 플러그인 확인
|
|
$plugin = $this->pluginRepository->findByIdentifier($identifier);
|
|
|
|
if (! $plugin || $plugin->status !== ExtensionStatus::Active->value) {
|
|
return [
|
|
'success' => false,
|
|
'filePath' => null,
|
|
'mimeType' => null,
|
|
'error' => 'plugin_not_found',
|
|
];
|
|
}
|
|
|
|
// 2. 파일 경로 구성 (플러그인 루트 기준)
|
|
$filePath = base_path("plugins/{$identifier}/{$path}");
|
|
|
|
// 3. 파일 존재 확인
|
|
if (! file_exists($filePath) || ! is_file($filePath)) {
|
|
return [
|
|
'success' => false,
|
|
'filePath' => null,
|
|
'mimeType' => null,
|
|
'error' => 'file_not_found',
|
|
];
|
|
}
|
|
|
|
// 4. MIME 타입 감지
|
|
$mimeType = $this->getMimeType($filePath);
|
|
|
|
return [
|
|
'success' => true,
|
|
'filePath' => $filePath,
|
|
'mimeType' => $mimeType,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 플러그인 편집기 스펙(editor-spec.json) 디코드 결과를 반환합니다.
|
|
*
|
|
* 활성 플러그인만 대상으로 하며, 활성 디렉토리 → _bundled 폴백 순으로 읽습니다.
|
|
* editor-spec.json 은 수작업 작성 파일로 플러그인 루트(plugin.json 옆)에 둡니다.
|
|
* 파일 미존재/미작성 시 spec=null 로 폴백합니다(편집 컨트롤 부재).
|
|
*
|
|
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
|
|
* @return array{success: bool, spec: array<string, mixed>|null, error: string|null}
|
|
*/
|
|
public function getEditorSpec(string $identifier): array
|
|
{
|
|
$plugin = $this->pluginRepository->findByIdentifier($identifier);
|
|
|
|
if (! $plugin || $plugin->status !== ExtensionStatus::Active->value) {
|
|
return ['success' => false, 'spec' => null, 'error' => 'plugin_not_found'];
|
|
}
|
|
|
|
// 분할 editor-spec.json 은 manifest + `$include` 블록으로 구성되므로
|
|
// 단순 디코드가 아닌 합본이 필요하다. 활성 디렉토리만 기준으로 합본한다
|
|
// (_bundled 폴백 없음). _bundled 작업분은 plugin:update 로 활성에
|
|
// 반영된 뒤에만 런타임에 보인다. 미분할 파일은 원본 그대로(하위 호환).
|
|
$spec = EditorSpecAssembler::assemble(
|
|
base_path("plugins/{$identifier}/editor-spec.json")
|
|
);
|
|
|
|
return ['success' => true, 'spec' => $spec, 'error' => null];
|
|
}
|
|
|
|
/**
|
|
* 플러그인 컴포넌트 매니페스트(components.json) 디코드 결과를 반환합니다.
|
|
*
|
|
* components.json 은 plugin:build 산출물로 플러그인 루트에 둡니다.
|
|
* 활성 디렉토리 → _bundled 폴백 순으로 읽으며, 미생성(구버전 플러그인) 시
|
|
* components=null 로 폴백합니다(무손실 보존 디그레이드 — 원칙 4.6).
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return array{success: bool, components: array<string, mixed>|null, error: string|null}
|
|
*/
|
|
public function getComponents(string $identifier): array
|
|
{
|
|
$plugin = $this->pluginRepository->findByIdentifier($identifier);
|
|
|
|
if (! $plugin || $plugin->status !== ExtensionStatus::Active->value) {
|
|
return ['success' => false, 'components' => null, 'error' => 'plugin_not_found'];
|
|
}
|
|
|
|
$components = $this->readJsonWithBundledFallback("plugins/{$identifier}/components.json");
|
|
|
|
return ['success' => true, 'components' => $components, 'error' => null];
|
|
}
|
|
|
|
/**
|
|
* 활성 디렉토리 → _bundled 폴백 순으로 JSON 파일을 읽어 디코드합니다.
|
|
*
|
|
* @param string $relativePath base_path 기준 상대 경로 (plugins/{id}/file.json)
|
|
* @return array<string, mixed>|null 디코드된 배열, 미존재/파싱 실패 시 null
|
|
*/
|
|
private function readJsonWithBundledFallback(string $relativePath): ?array
|
|
{
|
|
$bundledPath = preg_replace('#^plugins/#', 'plugins/_bundled/', $relativePath, 1);
|
|
$candidates = [base_path($relativePath), base_path((string) $bundledPath)];
|
|
|
|
foreach ($candidates as $path) {
|
|
if (! file_exists($path) || ! is_file($path)) {
|
|
continue;
|
|
}
|
|
$decoded = json_decode((string) file_get_contents($path), true);
|
|
if (is_array($decoded)) {
|
|
return $decoded;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* MIME 타입 감지
|
|
*
|
|
* @param string $filePath 파일 경로
|
|
* @return string MIME 타입
|
|
*/
|
|
private function getMimeType(string $filePath): string
|
|
{
|
|
$mimeTypes = [
|
|
'js' => 'application/javascript',
|
|
'mjs' => 'application/javascript',
|
|
'css' => 'text/css',
|
|
'json' => 'application/json',
|
|
'png' => 'image/png',
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'svg' => 'image/svg+xml',
|
|
'webp' => 'image/webp',
|
|
'gif' => 'image/gif',
|
|
'ico' => 'image/x-icon',
|
|
'woff' => 'font/woff',
|
|
'woff2' => 'font/woff2',
|
|
'ttf' => 'font/ttf',
|
|
'otf' => 'font/otf',
|
|
'eot' => 'application/vnd.ms-fontobject',
|
|
];
|
|
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
|
|
return $mimeTypes[$extension] ?? 'application/octet-stream';
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 변경 내역(changelog)을 조회합니다.
|
|
*
|
|
* source가 'github'이면 GitHub에서 원격 CHANGELOG.md를 가져와 파싱합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @param string|null $source 소스 ('active', 'bundled', 'github')
|
|
* @param string|null $fromVersion 시작 버전 (초과)
|
|
* @param string|null $toVersion 끝 버전 (이하)
|
|
* @return array 변경 내역 배열
|
|
*/
|
|
public function getPluginChangelog(string $identifier, ?string $source = null, ?string $fromVersion = null, ?string $toVersion = null): array
|
|
{
|
|
// GitHub 소스: 원격에서 CHANGELOG.md를 가져옴
|
|
if ($source === 'github') {
|
|
return $this->fetchRemoteChangelog($identifier, $fromVersion, $toVersion);
|
|
}
|
|
|
|
$basePath = base_path('plugins');
|
|
$filePath = ChangelogParser::resolveChangelogPath($basePath, $identifier, $source);
|
|
|
|
if ($filePath === null) {
|
|
return [];
|
|
}
|
|
|
|
if ($fromVersion !== null && $toVersion !== null) {
|
|
return ChangelogParser::getVersionRange($filePath, $fromVersion, $toVersion);
|
|
}
|
|
|
|
return ChangelogParser::parse($filePath);
|
|
}
|
|
|
|
/**
|
|
* GitHub에서 원격 CHANGELOG.md를 가져와 파싱합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @param string|null $fromVersion 시작 버전 (초과)
|
|
* @param string|null $toVersion 끝 버전 (이하)
|
|
* @return array 변경 내역 배열
|
|
*/
|
|
private function fetchRemoteChangelog(string $identifier, ?string $fromVersion = null, ?string $toVersion = null): array
|
|
{
|
|
$plugin = $this->pluginManager->getPlugin($identifier);
|
|
|
|
if (! $plugin) {
|
|
return [];
|
|
}
|
|
|
|
$githubUrl = $plugin->getGithubUrl();
|
|
|
|
if (empty($githubUrl)) {
|
|
return $this->getPluginChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
try {
|
|
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
|
} catch (\RuntimeException $e) {
|
|
return $this->getPluginChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
$ref = $toVersion ?? 'main';
|
|
$content = GithubHelper::fetchRawFile($owner, $repo, $ref, 'CHANGELOG.md');
|
|
|
|
if ($content === null) {
|
|
return $this->getPluginChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
if ($fromVersion !== null && $toVersion !== null) {
|
|
return ChangelogParser::getVersionRangeFromString($content, $fromVersion, $toVersion);
|
|
}
|
|
|
|
return ChangelogParser::parseFromString($content);
|
|
}
|
|
|
|
/**
|
|
* ZIP 파일에서 플러그인을 설치합니다.
|
|
*
|
|
* @param UploadedFile $file 업로드된 ZIP 파일
|
|
* @return array 설치된 플러그인 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
/**
|
|
* 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
|
|
*
|
|
* 사용자가 플러그인 설치 전 plugin.json 검증 실패 사유를 미리 확인할 수 있게 합니다.
|
|
*
|
|
* @param UploadedFile $file 업로드된 ZIP 파일
|
|
* @return array{manifest: ?array<string, mixed>, validation: array<string, mixed>} 미리보기 결과
|
|
*/
|
|
public function previewManifest(UploadedFile $file): array
|
|
{
|
|
$tempPath = storage_path('app/temp/plugins');
|
|
$extractPath = $tempPath.'/preview-'.uniqid('plugin_');
|
|
$manifest = null;
|
|
$errors = [];
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$file->getRealPath(), $extractPath, 'plugin.json', 'plugins'
|
|
);
|
|
|
|
$manifest = $result['config'];
|
|
} catch (\Throwable $e) {
|
|
$errors[] = $e->getMessage();
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
}
|
|
|
|
$existing = $manifest && ! empty($manifest['identifier'])
|
|
? $this->pluginRepository->findByIdentifier($manifest['identifier'])
|
|
: null;
|
|
|
|
return [
|
|
'manifest' => $manifest,
|
|
'validation' => [
|
|
'errors' => $errors,
|
|
'is_valid' => $errors === [] && $manifest !== null,
|
|
'already_installed' => $existing !== null,
|
|
'existing_version' => $existing?->version,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 업로드된 ZIP 파일로부터 플러그인을 추출/검증하고 _pending 으로 이동 후 설치합니다.
|
|
*
|
|
* `plugin.json` 검증 → identifier 충돌 검사 → _pending 이동 → 설치 파이프라인 진입.
|
|
*
|
|
* @param UploadedFile $file 사용자가 업로드한 플러그인 ZIP 파일
|
|
* @return array 설치 결과 (identifier/version/installed_at 포함)
|
|
*/
|
|
public function installFromZipFile(UploadedFile $file): array
|
|
{
|
|
$tempPath = storage_path('app/temp/plugins');
|
|
$extractPath = $tempPath.'/'.uniqid('plugin_');
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$file->getRealPath(), $extractPath, 'plugin.json', 'plugins'
|
|
);
|
|
|
|
$this->ensurePluginNotInstalled($result['identifier']);
|
|
|
|
ZipInstallHelper::moveToPending(
|
|
$result['sourcePath'], base_path('plugins/_pending'), $result['identifier']
|
|
);
|
|
|
|
try {
|
|
return $this->executePluginInstall($result['identifier']);
|
|
} catch (\Throwable $e) {
|
|
$pendingPath = base_path('plugins/_pending/'.$result['identifier']);
|
|
if (File::exists($pendingPath)) {
|
|
File::deleteDirectory($pendingPath);
|
|
}
|
|
throw $e;
|
|
}
|
|
} catch (PluginOperationException $e) {
|
|
throw $e;
|
|
} catch (\RuntimeException $e) {
|
|
// ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
|
|
// 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
|
|
// catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
|
|
throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub 저장소에서 플러그인을 설치합니다.
|
|
*
|
|
* @param string $githubUrl GitHub 저장소 URL
|
|
* @return array 설치된 플러그인 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
public function installFromGithub(string $githubUrl): array
|
|
{
|
|
$tempPath = storage_path('app/temp/plugins');
|
|
$extractPath = $tempPath.'/'.uniqid('plugin_');
|
|
$zipPath = null;
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
|
|
|
$token = config('app.update.github_token') ?? '';
|
|
|
|
if (! GithubHelper::checkRepoExists($owner, $repo, $token)) {
|
|
throw new PluginOperationException('plugins.errors.github_repo_not_found');
|
|
}
|
|
|
|
$zipPath = GithubHelper::downloadZip($owner, $repo, $tempPath, $token);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$zipPath, $extractPath, 'plugin.json', 'plugins'
|
|
);
|
|
|
|
$this->ensurePluginNotInstalled($result['identifier']);
|
|
|
|
ZipInstallHelper::moveToPending(
|
|
$result['sourcePath'], base_path('plugins/_pending'), $result['identifier']
|
|
);
|
|
|
|
try {
|
|
return $this->executePluginInstall($result['identifier']);
|
|
} catch (\Throwable $e) {
|
|
$pendingPath = base_path('plugins/_pending/'.$result['identifier']);
|
|
if (File::exists($pendingPath)) {
|
|
File::deleteDirectory($pendingPath);
|
|
}
|
|
throw $e;
|
|
}
|
|
} catch (PluginOperationException $e) {
|
|
throw $e;
|
|
} catch (\RuntimeException $e) {
|
|
// GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
|
|
// 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
|
|
throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
if ($zipPath && File::exists($zipPath)) {
|
|
File::delete($zipPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 이미 설치되어 있는지 확인합니다.
|
|
*
|
|
* _bundled/_pending에만 존재하는 경우(is_installed=false)는 설치 허용합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
*
|
|
* @throws \RuntimeException 이미 설치된 경우
|
|
*/
|
|
private function ensurePluginNotInstalled(string $identifier): void
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
$existingPlugin = $this->pluginManager->getPluginInfo($identifier);
|
|
|
|
if ($existingPlugin && $existingPlugin['is_installed']) {
|
|
throw new PluginOperationException('plugins.errors.already_installed', ['plugin' => $identifier]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _pending에서 플러그인을 설치합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return array 설치된 플러그인 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
private function executePluginInstall(string $identifier): array
|
|
{
|
|
$this->pluginManager->loadPlugins();
|
|
$result = $this->pluginManager->installPlugin($identifier, null, VendorMode::Auto, false, $failureReason);
|
|
|
|
if (! $result) {
|
|
// 사유를 실어 올리지 않으면 'plugins.errors.install_failed' 의 치환 자리가
|
|
// 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
|
|
throw new PluginOperationException('plugins.errors.install_failed', [
|
|
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
|
|
]);
|
|
}
|
|
|
|
return $this->pluginManager->getPluginInfo($identifier);
|
|
}
|
|
}
|