https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때 HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로 끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라 PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만 기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다. 캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로 수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면 안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다 (출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다). 그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다. - 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대 시끄러운 실패)은 호출부가 정하게 둔다. - 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트 분기는 config/filesystems.php 한 줄에서 끝낸다. 함께 고친 것 - 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아, 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다. - PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만 골라 별도 로더로 등록한다. - 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지 못하던 문제. - 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이 쓰이던 문제. 동반 산출물 - 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서 - audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로 판정식은 픽스처 36건이 잠근다 - INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
494 lines
14 KiB
PHP
494 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Extension\Helpers;
|
|
|
|
use App\Support\ExtensionStoragePath;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 확장 환경설정 마이그레이션 헬퍼
|
|
*
|
|
* UpgradeStep 내에서 사용하여 설정 파일의 스키마를 안전하게 변경합니다.
|
|
* 모듈은 카테고리별 다중 파일, 플러그인은 단일 파일 구조를 지원합니다.
|
|
*
|
|
* @example
|
|
* SettingsMigrator::forModule('sirsoft-ecommerce')
|
|
* ->addField('shipping.tracking_enabled', false)
|
|
* ->renameField('order_settings.auto_cancel_days', 'order_settings.pending_cancel_days')
|
|
* ->removeField('seo.deprecated_field')
|
|
* ->addCategory('notifications', ['email_enabled' => true])
|
|
* ->apply();
|
|
*/
|
|
class SettingsMigrator
|
|
{
|
|
/**
|
|
* 확장 타입 ('module' 또는 'plugin')
|
|
*/
|
|
private string $type;
|
|
|
|
/**
|
|
* 확장 식별자
|
|
*/
|
|
private string $identifier;
|
|
|
|
/**
|
|
* 적용할 오퍼레이션 큐
|
|
*
|
|
* @var array<int, array{operation: string, args: array}>
|
|
*/
|
|
private array $operations = [];
|
|
|
|
/**
|
|
* @param string $type 확장 타입 ('module' 또는 'plugin')
|
|
* @param string $identifier 확장 식별자
|
|
*/
|
|
private function __construct(string $type, string $identifier)
|
|
{
|
|
$this->type = $type;
|
|
$this->identifier = $identifier;
|
|
}
|
|
|
|
/**
|
|
* 모듈용 마이그레이터를 생성합니다.
|
|
*
|
|
* @param string $identifier 모듈 식별자
|
|
* @return static
|
|
*/
|
|
public static function forModule(string $identifier): static
|
|
{
|
|
return new static('module', $identifier);
|
|
}
|
|
|
|
/**
|
|
* 플러그인용 마이그레이터를 생성합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return static
|
|
*/
|
|
public static function forPlugin(string $identifier): static
|
|
{
|
|
return new static('plugin', $identifier);
|
|
}
|
|
|
|
/**
|
|
* 새 필드를 추가합니다. 이미 존재하면 스킵합니다.
|
|
*
|
|
* @param string $path dot notation 경로 (모듈: 'category.field', 플러그인: 'field')
|
|
* @param mixed $default 기본값
|
|
* @return $this
|
|
*/
|
|
public function addField(string $path, mixed $default): static
|
|
{
|
|
$this->operations[] = [
|
|
'operation' => 'addField',
|
|
'args' => ['path' => $path, 'default' => $default],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 필드 이름을 변경합니다. 이전 키가 없으면 스킵합니다.
|
|
*
|
|
* @param string $oldPath 이전 경로 (dot notation)
|
|
* @param string $newPath 새 경로 (dot notation)
|
|
* @return $this
|
|
*/
|
|
public function renameField(string $oldPath, string $newPath): static
|
|
{
|
|
$this->operations[] = [
|
|
'operation' => 'renameField',
|
|
'args' => ['oldPath' => $oldPath, 'newPath' => $newPath],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 필드를 제거합니다. 키가 없으면 스킵합니다.
|
|
*
|
|
* @param string $path 제거할 경로 (dot notation)
|
|
* @return $this
|
|
*/
|
|
public function removeField(string $path): static
|
|
{
|
|
$this->operations[] = [
|
|
'operation' => 'removeField',
|
|
'args' => ['path' => $path],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 필드 값을 변환합니다. 키가 없으면 스킵합니다.
|
|
*
|
|
* @param string $path 변환할 경로 (dot notation)
|
|
* @param callable $transformer 변환 함수 (현재 값을 받아 새 값 반환)
|
|
* @return $this
|
|
*/
|
|
public function transformField(string $path, callable $transformer): static
|
|
{
|
|
$this->operations[] = [
|
|
'operation' => 'transformField',
|
|
'args' => ['path' => $path, 'transformer' => $transformer],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 새 카테고리를 추가합니다. 모듈 전용입니다.
|
|
*
|
|
* 카테고리 파일을 생성하고 defaults.json의 _meta.categories 배열을 업데이트합니다.
|
|
*
|
|
* @param string $category 카테고리 이름
|
|
* @param array $defaults 기본값
|
|
* @return $this
|
|
*
|
|
* @throws \LogicException 플러그인에서 호출 시
|
|
*/
|
|
public function addCategory(string $category, array $defaults): static
|
|
{
|
|
$this->operations[] = [
|
|
'operation' => 'addCategory',
|
|
'args' => ['category' => $category, 'defaults' => $defaults],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 모든 오퍼레이션을 실행합니다.
|
|
*
|
|
* @return array{applied: int, skipped: int, errors: array<string>}
|
|
*/
|
|
public function apply(): array
|
|
{
|
|
$result = ['applied' => 0, 'skipped' => 0, 'errors' => []];
|
|
|
|
foreach ($this->operations as $op) {
|
|
try {
|
|
$method = 'execute'.ucfirst($op['operation']);
|
|
$applied = $this->{$method}($op['args']);
|
|
|
|
if ($applied) {
|
|
$result['applied']++;
|
|
} else {
|
|
$result['skipped']++;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$result['errors'][] = "[{$op['operation']}] {$e->getMessage()}";
|
|
|
|
Log::warning('SettingsMigrator 오퍼레이션 실패', [
|
|
'type' => $this->type,
|
|
'identifier' => $this->identifier,
|
|
'operation' => $op['operation'],
|
|
'args' => array_filter($op['args'], fn ($v) => ! is_callable($v)),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* addField 오퍼레이션을 실행합니다.
|
|
*
|
|
* @param array $args 오퍼레이션 인수
|
|
* @return bool 적용 여부
|
|
*/
|
|
private function executeAddField(array $args): bool
|
|
{
|
|
$path = $args['path'];
|
|
$default = $args['default'];
|
|
|
|
[$filePath, $fieldPath] = $this->resolveFilePath($path);
|
|
|
|
if (! File::exists($filePath)) {
|
|
return false;
|
|
}
|
|
|
|
$data = $this->readJsonFile($filePath);
|
|
|
|
if (Arr::has($data, $fieldPath)) {
|
|
return false;
|
|
}
|
|
|
|
Arr::set($data, $fieldPath, $default);
|
|
$this->writeJsonFile($filePath, $data);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* renameField 오퍼레이션을 실행합니다.
|
|
*
|
|
* @param array $args 오퍼레이션 인수
|
|
* @return bool 적용 여부
|
|
*/
|
|
private function executeRenameField(array $args): bool
|
|
{
|
|
$oldPath = $args['oldPath'];
|
|
$newPath = $args['newPath'];
|
|
|
|
[$oldFilePath, $oldFieldPath] = $this->resolveFilePath($oldPath);
|
|
[$newFilePath, $newFieldPath] = $this->resolveFilePath($newPath);
|
|
|
|
if (! File::exists($oldFilePath)) {
|
|
return false;
|
|
}
|
|
|
|
$oldData = $this->readJsonFile($oldFilePath);
|
|
|
|
if (! Arr::has($oldData, $oldFieldPath)) {
|
|
return false;
|
|
}
|
|
|
|
$value = Arr::get($oldData, $oldFieldPath);
|
|
|
|
// 같은 파일 내 이동
|
|
if ($oldFilePath === $newFilePath) {
|
|
Arr::forget($oldData, $oldFieldPath);
|
|
Arr::set($oldData, $newFieldPath, $value);
|
|
$this->writeJsonFile($oldFilePath, $oldData);
|
|
} else {
|
|
// 다른 파일 간 이동
|
|
Arr::forget($oldData, $oldFieldPath);
|
|
$this->writeJsonFile($oldFilePath, $oldData);
|
|
|
|
if (! File::exists($newFilePath)) {
|
|
return false;
|
|
}
|
|
|
|
$newData = $this->readJsonFile($newFilePath);
|
|
Arr::set($newData, $newFieldPath, $value);
|
|
$this->writeJsonFile($newFilePath, $newData);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* removeField 오퍼레이션을 실행합니다.
|
|
*
|
|
* @param array $args 오퍼레이션 인수
|
|
* @return bool 적용 여부
|
|
*/
|
|
private function executeRemoveField(array $args): bool
|
|
{
|
|
$path = $args['path'];
|
|
|
|
[$filePath, $fieldPath] = $this->resolveFilePath($path);
|
|
|
|
if (! File::exists($filePath)) {
|
|
return false;
|
|
}
|
|
|
|
$data = $this->readJsonFile($filePath);
|
|
|
|
if (! Arr::has($data, $fieldPath)) {
|
|
return false;
|
|
}
|
|
|
|
Arr::forget($data, $fieldPath);
|
|
$this->writeJsonFile($filePath, $data);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* transformField 오퍼레이션을 실행합니다.
|
|
*
|
|
* @param array $args 오퍼레이션 인수
|
|
* @return bool 적용 여부
|
|
*/
|
|
private function executeTransformField(array $args): bool
|
|
{
|
|
$path = $args['path'];
|
|
$transformer = $args['transformer'];
|
|
|
|
[$filePath, $fieldPath] = $this->resolveFilePath($path);
|
|
|
|
if (! File::exists($filePath)) {
|
|
return false;
|
|
}
|
|
|
|
$data = $this->readJsonFile($filePath);
|
|
|
|
if (! Arr::has($data, $fieldPath)) {
|
|
return false;
|
|
}
|
|
|
|
$currentValue = Arr::get($data, $fieldPath);
|
|
$newValue = $transformer($currentValue);
|
|
|
|
Arr::set($data, $fieldPath, $newValue);
|
|
$this->writeJsonFile($filePath, $data);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* addCategory 오퍼레이션을 실행합니다.
|
|
*
|
|
* @param array $args 오퍼레이션 인수
|
|
* @return bool 적용 여부
|
|
*
|
|
* @throws \LogicException 플러그인에서 호출 시
|
|
*/
|
|
private function executeAddCategory(array $args): bool
|
|
{
|
|
if ($this->type === 'plugin') {
|
|
throw new \LogicException(__('exceptions.settings.add_category_module_only'));
|
|
}
|
|
|
|
$category = $args['category'];
|
|
$defaults = $args['defaults'];
|
|
|
|
$settingsDir = $this->getSettingsDir();
|
|
$categoryFile = $settingsDir.'/'.$category.'.json';
|
|
|
|
// 카테고리 파일이 이미 존재하면 스킵
|
|
if (File::exists($categoryFile)) {
|
|
return false;
|
|
}
|
|
|
|
// 디렉토리 확인 및 생성
|
|
if (! File::isDirectory($settingsDir)) {
|
|
File::makeDirectory($settingsDir, 0755, true);
|
|
}
|
|
|
|
// 카테고리 파일 생성
|
|
$this->writeJsonFile($categoryFile, $defaults);
|
|
|
|
// defaults.json의 _meta.categories 업데이트
|
|
$this->updateDefaultsJsonCategories($category);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* dot notation 경로를 파일 경로와 필드 경로로 분리합니다.
|
|
*
|
|
* @param string $path dot notation 경로
|
|
* @return array{0: string, 1: string} [파일 경로, 필드 경로]
|
|
*/
|
|
private function resolveFilePath(string $path): array
|
|
{
|
|
$settingsDir = $this->getSettingsDir();
|
|
|
|
if ($this->type === 'module') {
|
|
// 모듈: 'category.field.subfield' → category.json 내 'field.subfield'
|
|
$dotPos = strpos($path, '.');
|
|
if ($dotPos === false) {
|
|
// 카테고리 자체를 대상으로 하는 경우 (addCategory에서 처리)
|
|
return [$settingsDir.'/'.$path.'.json', ''];
|
|
}
|
|
|
|
$category = substr($path, 0, $dotPos);
|
|
$fieldPath = substr($path, $dotPos + 1);
|
|
|
|
return [$settingsDir.'/'.$category.'.json', $fieldPath];
|
|
}
|
|
|
|
// 플러그인: 'field.subfield' → setting.json 내 'field.subfield'
|
|
return [$settingsDir.'/setting.json', $path];
|
|
}
|
|
|
|
/**
|
|
* 설정 디렉토리 경로를 반환합니다.
|
|
*
|
|
* @return string 설정 디렉토리 절대 경로
|
|
*/
|
|
private function getSettingsDir(): string
|
|
{
|
|
if ($this->type === 'module') {
|
|
return ExtensionStoragePath::module($this->identifier, 'settings');
|
|
}
|
|
|
|
return ExtensionStoragePath::plugin($this->identifier, 'settings');
|
|
}
|
|
|
|
/**
|
|
* JSON 파일을 읽어 배열로 반환합니다.
|
|
*
|
|
* @param string $filePath 파일 절대 경로
|
|
* @return array 파싱된 데이터
|
|
*
|
|
* @throws \RuntimeException JSON 파싱 실패 시
|
|
*/
|
|
private function readJsonFile(string $filePath): array
|
|
{
|
|
$content = File::get($filePath);
|
|
$data = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new \RuntimeException(
|
|
__('exceptions.settings.json_parse_failed', [
|
|
'path' => $filePath,
|
|
'error' => json_last_error_msg(),
|
|
])
|
|
);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 배열 데이터를 JSON 파일로 저장합니다.
|
|
*
|
|
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때 root 소유로
|
|
* 파일이 만들어지지 않도록, 작성 직후 부모 디렉토리(예: storage/app/modules/{id}/settings/)
|
|
* 의 owner/group 을 상속한다. 부모 owner 가 PHP-FPM 이라면 후속 PHP-FPM 의 update 시도
|
|
* 가 쓰기 실패하지 않는다.
|
|
*
|
|
* @param string $filePath 파일 절대 경로
|
|
* @param array $data 저장할 데이터
|
|
* @return void
|
|
*/
|
|
private function writeJsonFile(string $filePath, array $data): void
|
|
{
|
|
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
File::put($filePath, $content);
|
|
|
|
// sudo 컨텍스트 root → 부모 owner 정합화. 자기 자신 owner 면 멱등 (no-op).
|
|
FilePermissionHelper::inheritOwnershipFromParent($filePath);
|
|
}
|
|
|
|
/**
|
|
* defaults.json의 _meta.categories 배열에 새 카테고리를 추가합니다.
|
|
*
|
|
* @param string $category 추가할 카테고리 이름
|
|
* @return void
|
|
*/
|
|
private function updateDefaultsJsonCategories(string $category): void
|
|
{
|
|
$defaultsPath = base_path("modules/{$this->identifier}/config/settings/defaults.json");
|
|
|
|
if (! File::exists($defaultsPath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$data = $this->readJsonFile($defaultsPath);
|
|
$categories = $data['_meta']['categories'] ?? [];
|
|
|
|
if (! in_array($category, $categories)) {
|
|
$categories[] = $category;
|
|
$data['_meta']['categories'] = $categories;
|
|
$this->writeJsonFile($defaultsPath, $data);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('defaults.json 카테고리 업데이트 실패', [
|
|
'module' => $this->identifier,
|
|
'category' => $category,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|