Files
Gnuboard7/bootstrap/app.php
T
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지
않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를
--no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는
`composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를
찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상
종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다.
(sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10)

3계층으로 막는다.

 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear
 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다.
 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전
 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다.
 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로
 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을
 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI
 자체에 도달할 수 없다).

업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고
CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의
복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다.

실측 중 드러난 결함 2건을 함께 고쳤다.

 - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아
 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다.
 - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다
 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다).

인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인
카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를
정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다.
코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3).

코어 7.0.10 → 7.0.11.
2026-09-08 09:46:57 +09:00

249 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Exceptions\CannotModifyProtectedRoleException;
use App\Exceptions\CannotModifySuperAdminException;
use App\Exceptions\CoreVersionMismatchException;
use App\Exceptions\IdentityVerificationRequiredException;
use App\Exceptions\PermissionEscalationException;
use App\Helpers\ResponseHelper;
use App\Http\Middleware\AdminMiddleware;
use App\Http\Middleware\CheckTemplateDependencies;
use App\Http\Middleware\CheckUserStatus;
use App\Http\Middleware\DatabaseCredentialGuard;
use App\Http\Middleware\EnforceIdentityPolicy;
use App\Http\Middleware\EnsureDebugMode;
use App\Http\Middleware\ExtensionMiddlewareGate;
use App\Http\Middleware\GzipEncodeResponse;
use App\Http\Middleware\MaintenanceModePage;
use App\Http\Middleware\OptionalSanctumMiddleware;
use App\Http\Middleware\PermissionMiddleware;
use App\Http\Middleware\RefreshTokenExpiration;
use App\Http\Middleware\RoleMiddleware;
use App\Http\Middleware\SetLocale;
use App\Http\Middleware\SetTimezone;
use App\Http\Middleware\StartApiSession;
use App\Http\Middleware\SyncBoostWithDebugMode;
use App\Seo\SeoMiddleware;
use App\Support\UmaskHelper;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
use Illuminate\Http\Request;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Group-Shared umask Alignment
|--------------------------------------------------------------------------
|
| 운영자가 `storage/` 를 그룹 쓰기(g+w) 로 설정한 경우, Laravel 이 런타임에
| 생성하는 새 디렉토리(예: `storage/framework/cache/data/<hash>`) 도 g+w 를
| 유지하도록 프로세스 umask 를 0002 로 조정한다. 이 동조가 없으면 기본 umask 022
| 로 인해 `0755` (drwxr-xr-x) 로 만들어져 php-fpm(www-data) 그룹 쓰기가 실패한다.
|
| `storage/` 에 g-w 가 설정된 경우(일부 공유 호스팅 특수 환경) 에는 운영자
| 의도를 존중하여 umask 를 건드리지 않는다. `umask` 함수 자체가 비활성인
| 환경에서도 조용히 스킵한다. 상세: App\Support\UmaskHelper.
|
*/
UmaskHelper::configureForGroupSharing(dirname(__DIR__).'/storage');
/*
|--------------------------------------------------------------------------
| Disable putenv() for Thread Safety
|--------------------------------------------------------------------------
|
| Apache mod_php 환경에서 동일 프로세스 내 여러 요청이 동시에 처리될 때,
| putenv()/getenv()는 thread-safe하지 않아 환경변수가 다른 요청에 의해
| 덮어씌워지는 문제가 발생합니다.
|
| 이 설정은 Dotenv가 putenv()를 사용하지 않고 $_ENV/$_SERVER만 사용하도록 합니다.
|
*/
Env::disablePutenv();
$app = Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function () {
// DevTools 라우트 — 디버그 모드 게이트를 **그룹 단위**로 건다.
//
// 이 한 줄이 `routes/devtools.php` 의 8개 라우트(GET·POST·DELETE) 전부를 덮는다.
// 게이트를 핸들러 안에 흩어 두면 라우트를 추가할 때마다 함께 적어야 하고, 빠뜨려도
// 예외·로그가 남지 않아 그 라우트만 조용히 열린다(공개#128 — DELETE clear 가
// production 에서 미인증 200 으로 storage/debug-dump 를 지웠다).
//
// `api` 그룹 전체가 아니라 devtools 래퍼에만 붙이는 것이 유일한 안전 지점이다.
Route::middleware(['api', 'debug.gate'])
->group(base_path('routes/devtools.php'));
},
)
->withMiddleware(function (Middleware $middleware): void {
// Laravel 기본 메인터넌스 미들웨어 제거 (커스텀 MaintenanceModePage로 대체)
$middleware->remove(PreventRequestsDuringMaintenance::class);
// DB 자격증명 가드 (인증 불필요) — 최고권한 계정/빈 사용자명으로 인한
// 확장 로딩 스킵을 전용 에러 페이지로 드러낸다.
//
// prepend() 는 새 항목을 기존 prepends 앞에 넣으므로, 아래 MaintenanceModePage
// prepend 가 뒤따르면 최종 순서는 [MaintenanceModePage, DatabaseCredentialGuard] 가
// 된다. 점검 중에는 점검 페이지가 우선해야 하므로 이 호출 순서를 유지한다.
$middleware->prepend(DatabaseCredentialGuard::class);
// Maintenance 모드 전용 페이지 미들웨어 (인증 불필요, 최우선 실행)
$middleware->prepend(MaintenanceModePage::class);
// Laravel Boost browser-logs를 G7 디버그 모드와 연동
// InjectBoost 미들웨어보다 먼저 실행되어야 하므로 최상단에 추가
$middleware->prependToGroup('web', SyncBoostWithDebugMode::class);
// SetLocale, SetTimezone은 인증 후 실행되어야 사용자 설정을 읽을 수 있음
$localeTimezoneMiddleware = [
SetLocale::class,
SetTimezone::class,
];
$middleware->appendToGroup('web', $localeTimezoneMiddleware);
$middleware->appendToGroup('api', $localeTimezoneMiddleware);
// Gzip 압축 미들웨어 (웹서버 설정 없이 애플리케이션 레벨에서 압축)
$middleware->append(GzipEncodeResponse::class);
// 토큰 만료 시간 슬라이딩 갱신 미들웨어 (API 요청 시 토큰 만료 시간 자동 연장)
$middleware->appendToGroup('api', [
RefreshTokenExpiration::class,
]);
// IDV 정책 자동 매핑 — 모든 API 라우트에서 라우트 이름과 매칭되는 scope='route' 정책을 자동 enforce.
// 정책 DB 토글만으로 즉시 효과 (라우트 코드 수정 불필요). hook scope 정책의 동적 구독과 동일 모델.
// 캐시된 인덱스 lookup → 무매칭 라우트는 O(1) 통과.
$middleware->appendToGroup('api', [
EnforceIdentityPolicy::class,
]);
// 확장 선언 미들웨어 self-gate 래퍼 — 확장이 getMiddleware() 로 선언한 미들웨어를
// 요청 시점에 라우트명·URI 매칭으로 실행. web/api × before_core/after_core 로 4회 등록.
// - before_core → 그룹 선두(prepend): 코어 전처리(인증·SetLocale·IDV) 전체보다 먼저 실행
// - after_core → 그룹 후미(append): 코어 그룹 미들웨어 뒤에서 실행
// Kernel 의 prepend/append 는 코어 내부 구현이고, 확장 개발자·게이트 파라미터는
// before_core/after_core 의도 명칭만 본다. 매칭 확장 미들웨어가 없으면 no-op.
foreach (['web', 'api'] as $extGroup) {
$middleware->prependToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},before_core");
$middleware->appendToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},after_core");
}
// 비인증 게스트 redirect 경로 가드 (공개#39).
// Laravel 12 기본값은 `redirectGuestsTo(fn () => route('login'))` 인데,
// 이 프로젝트엔 'login' 이름 라우트가 없어 Accept 헤더 없는 /api/* 비인증 요청이
// Authenticate::unauthenticated() 의 redirectTo() 평가 단계에서 route('login')
// → RouteNotFoundException(HTTP 500) 으로 떨어진다.
// API 경로는 redirect 대상을 null 로 반환해 RouteNotFoundException 을 차단하고,
// withExceptions 의 AuthenticationException 핸들러가 401 JSON 으로 응답하도록 위임한다.
$middleware->redirectGuestsTo(function (Request $request) {
// API 경로는 redirect 하지 않음 → AuthenticationException 으로 propagate → 401 JSON.
if ($request->expectsJson() || $request->is('api/*')) {
return null;
}
// web 경로는 'login' 이름 라우트가 정의된 경우에만 redirect (현재 프로젝트엔 없음 → null).
return Route::has('login') ? route('login') : null;
});
// 권한 관련 미들웨어 등록
$middleware->alias([
'admin' => AdminMiddleware::class,
'check.user_status' => CheckUserStatus::class,
'permission' => PermissionMiddleware::class,
'role' => RoleMiddleware::class,
'template.dependencies' => CheckTemplateDependencies::class,
'optional.sanctum' => OptionalSanctumMiddleware::class,
'start.api.session' => StartApiSession::class,
'seo' => SeoMiddleware::class,
'identity.policy' => EnforceIdentityPolicy::class,
'extension.middleware' => ExtensionMiddlewareGate::class,
'debug.gate' => EnsureDebugMode::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// API 401 응답 시 잔존 세션 쿠키 정리
$exceptions->render(function (AuthenticationException $e, Request $request) {
// API 경로는 Accept 헤더 유무와 무관하게 항상 JSON 401 (web redirect/route('login') 폴백 차단).
// 동일 파일의 IDV/CoreVersionMismatch 핸들러와 같은 가드 패턴.
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json(['message' => __('auth.unauthenticated')], 401)
->withCookie(cookie()->forget(config('session.cookie')));
}
});
// IDV 정책 위반 → HTTP 428 (Precondition Required) + verification payload
$exceptions->render(function (IdentityVerificationRequiredException $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {
return ResponseHelper::identityRequired($e->getPayload());
}
});
// 등급 상한(rank ceiling) / 권한 상승 차단 예외 → HTTP 403 (KVE-2026-1919).
// 컨트롤러 catch 가 우선 처리하지만, 새 호출부가 로컬 catch 없이 이 예외를 던져도
// 조용한 500 대신 일관된 403 이 되도록 전역 렌더러를 둔다(심층 방어). 메시지는 예외
// 생성자가 lang 키로 설정한 것을 그대로 쓴다 — 컨트롤러 매핑과 동일 문구.
$exceptions->render(function (PermissionEscalationException|CannotModifySuperAdminException|CannotModifyProtectedRoleException $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 403);
}
});
// 확장 코어 버전 호환성 검사 실패 → HTTP 422 + error_code: 'core_version_mismatch'
// (extension update/activate/recovery 등 사전 검증 진입 지점에서 throw)
$exceptions->render(function (CoreVersionMismatchException $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
'error_code' => 'core_version_mismatch',
'data' => $e->getPayload(),
], 422);
}
});
})->create();
/*
| Core Update: Stale Package Manifest Self-Heal — 코어 업데이트 트리 안에서 부팅하는 프로세스
| (core:update 부모, G7_UPDATE_IN_PROGRESS=1 을 물려받은 spawn 자식, 그 안의 config:cache/route:cache
| 일회용 앱)는 bootstrap/cache/packages.php · services.php 를 비우고 부팅한다. Laravel 은 두 파일이
| 없을 때만 vendor/composer/installed.json 에서 다시 만들므로, vendor 교체 뒤 남은 이전 설치본의
| 목록(dev composer 의 require-dev 전이 provider 등)으로 부팅하다 "Class ... not found" 로 죽는 일을
| 막는다. 7.0.11 미만 부모는 spawn 직전에 비우지 않으므로 그 부모 아래에서 도는 신버전 자식의
| 유일한 방어다 (7.0.9/7.0.10 → 7.0.11). 7.0.11+ 부모는 이미 비우므로 여기서는 no-op.
| 규칙: App\ 클래스를 참조하지 않는다 (부팅 전이라 오토로드를 신뢰할 수 없고, 자가 치유가 자기
| 실패로 부팅을 막아서는 안 된다). 판정은 App\Support\CoreUpdateContext::isInProgress() 와 동일 —
| 조건을 바꾸면 양쪽을 함께 고친다.
*/
$g7UpdateFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
$g7UpdateArgv = $_SERVER['argv'][1] ?? '';
if ($g7UpdateFlag === '1' || $g7UpdateFlag === 1 || $g7UpdateFlag === true
|| in_array($g7UpdateArgv, ['core:update', 'core:execute-upgrade-steps'], true)) {
try {
foreach ([$app->getCachedPackagesPath(), $app->getCachedServicesPath()] as $g7ManifestPath) {
if (is_file($g7ManifestPath)) {
@unlink($g7ManifestPath); // Windows 핸들 점유 시 실패 가능 — 무시(종전 동작)
}
}
clearstatcache();
} catch (Throwable) {
// 자가 치유 실패는 부팅을 막지 않는다.
}
}
unset($g7UpdateFlag, $g7UpdateArgv, $g7ManifestPath);
return $app;