fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
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 쓰기 권한 불요를 명시)
This commit is contained in:
1 parent
fc27a6edf6
commit
85e266d361
88 files changed
+1913
-332
No files matched your search
@@ -60,6 +60,22 @@ class ExtensionStaticCacheService
|
||||
/** 게시 트리 디렉토리 권한 — umask 무력화 대상 */
|
||||
private const PUBLISH_DIR_MODE = 0775;
|
||||
|
||||
/**
|
||||
* 게시 루트 확보 실패 사유 → 운영자가 읽는 요약.
|
||||
*
|
||||
* 키는 `FilePermissionHelper::ensureWritableDirectory()` 가 돌려주는 사유다. 사유마다
|
||||
* 고쳐야 할 대상이 다르므로(상위 디렉토리 / 그 자리의 파일 / 대상 자신) 한 문장으로
|
||||
* 뭉뚱그리지 않는다 — 뭉뚱그리면 실패 마커만 보고는 무엇을 고쳐야 할지 알 수 없다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const PREFLIGHT_FAILURE_SUMMARIES = [
|
||||
'ancestor_not_writable' => '게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다',
|
||||
'occupied_by_file' => '게시 루트 자리를 같은 이름의 파일이 차지하고 있습니다',
|
||||
'create_failed' => '게시 루트를 만들지 못했습니다',
|
||||
'not_writable' => '게시 루트에 쓸 수 없습니다',
|
||||
];
|
||||
|
||||
/** 게시 트리 디렉토리 rename 시도 횟수 (일시 거부 흡수) */
|
||||
private const RENAME_ATTEMPTS = 3;
|
||||
|
||||
@@ -650,70 +666,17 @@ class ExtensionStaticCacheService
|
||||
*/
|
||||
private function ensurePublishRootWritable(int $version): bool
|
||||
{
|
||||
$base = $this->baseDir();
|
||||
|
||||
if (! File::isDirectory($base)) {
|
||||
// 게시 루트가 아직 없으면 만든다. 검사 대상은 `public/build` 고정이 아니라
|
||||
// **실재하는 최근접 조상**이다 — `public/build` 자체가 없는 환경(신규 설치,
|
||||
// 테스트 격리 public 경로)에서 부모 존재를 요구하면 정상 상황을 실패로 만든다.
|
||||
$ancestor = $this->nearestExistingAncestor($base);
|
||||
|
||||
if ($ancestor === null || ! is_writable($ancestor)) {
|
||||
$this->failPreflight(
|
||||
$version,
|
||||
$ancestor ?? dirname($base),
|
||||
'게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 조상이 쓰기 가능해도 mkdir 은 실패할 수 있다 — 경로 중간이 **파일**이거나
|
||||
// 경합으로 사라지는 경우다. `ensureDirectoryExists` 는 그 실패를 예외로
|
||||
// 던지는데, 이 프리플라이트는 `publishVersion` 의 try 블록 **밖**에서 돌므로
|
||||
// 잡지 않으면 예외가 호출자에게 그대로 새어 나간다 — 게시 실패는 사이트를
|
||||
// 멈추지 않는다는 계약이 그 지점에서 깨진다.
|
||||
try {
|
||||
$this->makeDirectory($base);
|
||||
} catch (\Throwable $e) {
|
||||
$this->failPreflight($version, $base, '게시 루트를 만들지 못했습니다: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
// 확보는 코어 공통 프리미티브가 맡는다 — 검사 대상은 `public/build` 고정이 아니라
|
||||
// **실재하는 최근접 상위**이고(`public/build` 자체가 없는 신규 설치·테스트 격리 public
|
||||
// 경로에서 부모 존재를 요구하면 정상 상황을 실패로 만든다), 생성 실패는 예외가 아니라
|
||||
// 사유로 올라온다. 실패 정책(프리플라이트 실패 마커 + API 폴백)은 이 자리가 정한다.
|
||||
if (FilePermissionHelper::ensureWritableDirectory($this->baseDir(), self::PUBLISH_DIR_MODE, $failure)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearstatcache(true, $base);
|
||||
$this->failPreflight($version, $failure['path'], self::PREFLIGHT_FAILURE_SUMMARIES[$failure['reason']]);
|
||||
|
||||
if (! File::isDirectory($base) || ! is_writable($base)) {
|
||||
$this->failPreflight($version, $base, '게시 루트에 쓸 수 없습니다');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
|
||||
*
|
||||
* @param string $path 기준 경로
|
||||
* @return string|null 실재하는 최근접 조상 (루트까지 없으면 null)
|
||||
*/
|
||||
private function nearestExistingAncestor(string $path): ?string
|
||||
{
|
||||
$current = dirname($path);
|
||||
|
||||
while (! File::isDirectory($current)) {
|
||||
$parent = dirname($current);
|
||||
|
||||
if ($parent === $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $parent;
|
||||
}
|
||||
|
||||
return $current;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -771,10 +734,12 @@ class ExtensionStaticCacheService
|
||||
/**
|
||||
* 게시 트리 디렉토리를 만들고 umask 와 무관하게 권한·소유권을 정합화합니다.
|
||||
*
|
||||
* `File::ensureDirectoryExists($dir, 0775)` 의 mode 인자는 **umask 로 깎인다** —
|
||||
* umask 022 환경에서는 0755 가 되어 웹 계정(그룹 공유)이 쓸 수 없다. 명시 `chmod` 로
|
||||
* umask 를 무력화하고, 부모 소유권을 상속시켜 CLI 계정 고정을 막는다.
|
||||
* (선례: `CoreUpdateService::ensureWritableDirectories`)
|
||||
* 게시 파일마다 호출되는 뜨거운 경로라 쓰기 가능 여부를 매번 재판정하지 않는다 — 그 판정은
|
||||
* 게시 시작 전 `ensurePublishRootWritable()` 이 루트에서 한 번 수행한다. 여기서는 생성만
|
||||
* 하고, 실패는 **예외로 남긴다**: 호출부(`writeJson`/`copyFile`)가 게시 전체를 중단시키는
|
||||
* 계약이 그 예외에 걸려 있다.
|
||||
*
|
||||
* 권한 정합화는 코어 공통 프리미티브에 위임한다 (umask 무력화 + POSIX setgid + 소유권 상속).
|
||||
*
|
||||
* @param string $dir 생성할 디렉토리 절대 경로
|
||||
*/
|
||||
@@ -782,10 +747,7 @@ class ExtensionStaticCacheService
|
||||
{
|
||||
File::ensureDirectoryExists($dir, self::PUBLISH_DIR_MODE);
|
||||
|
||||
// ensureDirectoryExists 의 mode 는 umask 로 깎이므로 명시 chmod 로 확정한다.
|
||||
@chmod($dir, self::PUBLISH_DIR_MODE);
|
||||
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dir);
|
||||
FilePermissionHelper::hardenDirectory($dir, self::PUBLISH_DIR_MODE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user