diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffecb7a..1ce9f325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Fixed - 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.) -- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.) +- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. 관리자 권한으로 업데이트를 실행할 때 이 연결을 다시 이어 붙이면서 소유자가 관리자 계정으로 바뀌던 문제도 함께 바로잡아, 원래 계정 소유가 그대로 유지됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.) ## [7.0.4] - 2026-07-14 diff --git a/app/Extension/Helpers/StorageLinkHelper.php b/app/Extension/Helpers/StorageLinkHelper.php index d8e2cc05..67c2bb3c 100644 --- a/app/Extension/Helpers/StorageLinkHelper.php +++ b/app/Extension/Helpers/StorageLinkHelper.php @@ -117,6 +117,8 @@ class StorageLinkHelper protected static function createLink(string $target, string $link): bool { if (@symlink($target, $link)) { + static::inheritParentOwnership($link); + return true; } @@ -135,6 +137,39 @@ class StorageLinkHelper return false; } + /** + * 갓 생성한 링크의 소유자/그룹을 부모 디렉토리로부터 상속시킵니다. + * + * `symlink()` 는 소유권 지정 인자가 없어 실행 프로세스(sudo → root)의 소유로 링크를 + * 만든다. 이 때문에 sudo 로 실행된 코어 업데이트가 종료 시점에 `public/storage` 를 + * 재생성하면 링크가 root:root 로 남아, 원래 앱 실행 유저 소유였던 링크의 소유권이 + * 바뀌던 문제(#43 후속)를 차단한다. 부모 `public/` 의 owner/group 을 기준으로 삼아 + * 이 프로젝트의 "신규 항목은 부모 소유권 상속" 컨벤션(`FilePermissionHelper`)과 일치시킨다. + * + * `chown`/`chgrp` 은 심볼릭 링크의 target 을 따라가므로, 링크 *자체* 를 대상으로 하는 + * `lchown`/`lchgrp` 을 사용한다. 비-POSIX(Windows) 또는 함수 부재/권한 부족 시 무해하게 skip. + * + * @param string $link 방금 생성한 링크 경로 + */ + protected static function inheritParentOwnership(string $link): void + { + // lchown/lchgrp 은 POSIX 전용 — Windows 및 함수 비활성 환경에서는 no-op. + if (! function_exists('lchown') || ! function_exists('lchgrp')) { + return; + } + + $parent = dirname($link); + $owner = @fileowner($parent); + $group = @filegroup($parent); + + if ($owner !== false) { + @lchown($link, $owner); + } + if ($group !== false) { + @lchgrp($link, $group); + } + } + /** * 경로가 Windows JUNCTION 인지 판정합니다 (`is_link()` 미인식 reparse point). * diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 44e29a07..eadcb7f1 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -227,6 +227,7 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다. > - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님). > - **층 2 (복구)**: 업데이트 종료 시점(정상 Step 11 + 핸드오프 catch)에 `StorageLinkHelper::ensurePublicStorageLink()` 를 호출해 `public/storage` 가 정상 링크인지 확인하고, 부재/손상이면 `storage/app/public` 을 가리키는 링크를 (필요 시 `.broken.{YmdHis}` rename 백업 후) 재생성한다. 버전 무관 매 업데이트 실행. Windows `SeCreateSymbolicLink` 권한 부족 시 junction(`mklink /J`) 폴백까지 시도하고, 그래도 실패하면 rename 원복 + 수동 `storage:link` 안내(데이터 손실 없음). 롤백 catch 경로는 백업 복원이 링크를 원상 회복하므로 대상 아님. +> - **소유권 상속**: `symlink()` 은 소유권 인자가 없어 sudo 실행 시 링크가 root:root 로 생성된다. 재생성 직후 부모 `public/` 의 owner/group 을 기준으로 `lchown`/`lchgrp`(링크 자체 대상 — `chown` 은 target 을 따라감) 보정해 원래 앱 실행 유저 소유를 유지한다. 이 프로젝트의 "신규 항목은 부모 소유권 상속"(`FilePermissionHelper`) 컨벤션과 일치. 비-POSIX/함수 부재/권한 부족 시 무해하게 skip. > - 이 복구 로직은 beta.5 DataMigration `RecoverPublicStorageSymlink` 와 `StorageLinkHelper` 로 일원화되어 있다(부재→재생성 케이스까지 상위호환). > **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다. @@ -679,7 +680,7 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') | 메서드 | 시그니처 | 설명 | |--------|---------|------| | `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제. `public` 타깃은 `copyDirectory(..., preserveLinkPaths: ['storage'])` 로 `public/storage` symlink/junction 을 orphan 삭제에서 보호 (#43) | -| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) | +| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). 재생성 링크는 부모 `public/` 의 소유자/그룹을 `lchown`/`lchgrp` 상속(sudo 후 root:root 잔존 차단). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) | | `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) | | `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 | | `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install | diff --git a/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php index 84856f7f..231e48d5 100644 --- a/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php +++ b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php @@ -172,6 +172,57 @@ class StorageLinkHelperTest extends TestCase $this->assertTrue(is_link($this->fakeBase.'/public/storage')); } + /** + * 재생성된 링크의 소유자/그룹이 부모 public/ 디렉토리를 상속합니다 (#43 sudo 후속). + * + * sudo 로 실행된 종료 시점 복구가 `symlink()` 로 링크를 만들면 링크 소유자가 root:root 로 + * 남아, 원래 앱 실행 유저 소유였던 링크의 소유권이 바뀌던 문제. 링크 생성 직후 부모 + * 디렉토리의 owner/group 으로 `lchown`/`lchgrp` 보정하는지 검증한다. + * + * 실측 방법: uid 변경은 root 권한이 필요하므로, 부모 public/ 의 그룹을 프로세스의 보조 + * 그룹 중 하나로 바꾼 뒤 링크를 재생성 → 링크 그룹이 부모 그룹을 상속하는지로 검증한다 + * (sudo 상황에서 링크만 다른 소유로 남는 회귀와 동형). Windows / 보조 그룹 부재 시 skip. + */ + #[Test] + public function 재생성된_링크는_부모_디렉토리의_소유권을_상속합니다(): void + { + if (PHP_OS_FAMILY === 'Windows' || ! function_exists('lchgrp') || ! function_exists('posix_getgroups')) { + $this->markTestSkipped('링크 소유권 상속 검증은 POSIX(lchgrp/posix_getgroups) 환경 전용'); + } + + $this->skipIfNoSymlink(); + + $publicDir = $this->fakeBase.'/public'; + File::ensureDirectoryExists($publicDir); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // 프로세스가 chgrp 가능한 그룹 = 현재 그룹과 다른 보조 그룹을 찾는다. + $currentGid = posix_getgid(); + $candidateGids = array_values(array_filter(posix_getgroups(), fn ($g) => $g !== $currentGid)); + if ($candidateGids === []) { + $this->markTestSkipped('보조 그룹이 없어 소유권 상속 검증 불가'); + } + + // 부모 public/ 를 보조 그룹으로 변경 — 링크가 이 그룹을 상속해야 함. + $targetGid = $candidateGids[0]; + if (! @chgrp($publicDir, $targetGid)) { + $this->markTestSkipped('부모 디렉토리 chgrp 실패 (권한 부족)'); + } + + // public/storage 의도적 미생성 → 신규 생성 경로가 링크를 만든다. + StorageLinkHelper::ensurePublicStorageLink(); + + $linkPath = $this->fakeBase.'/public/storage'; + $this->assertTrue(is_link($linkPath), '링크가 생성되어야 한다'); + + $linkStat = lstat($linkPath); + $this->assertSame( + $targetGid, + $linkStat['gid'], + '재생성된 링크의 그룹은 부모 public/ 의 그룹을 상속해야 한다 (sudo 후 root 소유 잔존 차단, #43)', + ); + } + /** * public/storage 부재 시 junction 폴백으로 재생성 (Windows — symlink 권한 불요). *