From 062a88ca043185d864bffd1d5bd6944dccc7925a Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 20 Jul 2026 14:17:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20DB=20=EC=B5=9C=EA=B3=A0=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B3=84=EC=A0=95=20=EC=B0=A8=EB=8B=A8=20+=20OPcac?= =?UTF-8?q?he=20=EC=83=81=ED=83=9C=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설치 단계와 코어 부팅의 정책이 엇갈려 있었다. 인스톨러는 root 를 자유롭게 허용하면서, 설치 후 부팅은 같은 값을 보고 확장 로딩 전체를 조용히 스킵했다. 설치는 성공하지만 이후 깨진 화면만 뜨고 원인은 로그 한 줄에만 남았다. 판정을 프레임워크 의존성 0 인 공용 SSoT 로 모아 인스톨러와 코어가 같은 답을 내게 하고, 설치 단계에서 서버 2경로로 차단하며, 설치 후 변경은 전용 에러 페이지로 드러낸다. 가드를 boot 가 아닌 미들웨어에 두어 artisan 이 죽지 않게 해 복구 수단을 남겼다. admin 은 일반 권한 운영 계정 오차단을 막기 위해 차단 목록에서 제외했다. OPcache 는 상태 조회 경로 자체가 없어 성능 저하를 인지할 방법이 없었다. 설치 단계 경고(비차단)와 관리자 정보 탭 표시를 같은 판정 SSoT 로 추가했다. 확장 로드 여부만으로는 부족해 opcache.enable 지시자까지 확인하며, ini_get 이 차단된 환경은 "확인 불가"로 폴백해 경고도 차단도 하지 않는다. --- CHANGELOG.md | 4 + .../Concerns/DetectsRequestLocale.php | 42 +++ .../Middleware/DatabaseCredentialGuard.php | 85 +++++ app/Http/Middleware/MaintenanceModePage.php | 30 +- app/Providers/CoreServiceProvider.php | 27 +- app/Services/SettingsService.php | 17 + app/Support/OpcacheStatus.php | 59 ++++ app/Support/PrivilegedDatabaseAccounts.php | 90 +++++ bootstrap/app.php | 9 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 + .../backend/ja/database_credential.php | 10 + .../CHANGELOG.md | 1 + .../frontend/partial/admin.json | 7 +- lang/en/database_credential.php | 10 + lang/ko/database_credential.php | 10 + public/install/api/check-configuration.php | 41 ++- public/install/assets/js/installer.js | 76 +++++ public/install/includes/config.php | 13 + public/install/includes/request-handler.php | 28 ++ public/install/index.php | 3 + public/install/lang/en.php | 8 + public/install/lang/ko.php | 8 + public/install/views/3-configuration.php | 16 +- .../views/database-credential-error.blade.php | 137 ++++++++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 1 + .../admin-settings-info-opcache.test.tsx | 314 ++++++++++++++++++ .../lang/partial/en/admin.json | 5 + .../lang/partial/ko/admin.json | 5 + .../partials/admin_settings/_tab_info.json | 70 +++- .../Api/Admin/SettingsControllerTest.php | 145 ++++++++ .../DatabaseCredentialGuardTest.php | 305 +++++++++++++++++ .../specs/admin/settings-info-opcache.spec.ts | 112 +++++++ .../PrivilegedDbAccountGuardTest.php | 286 ++++++++++++++++ tests/Unit/Support/OpcacheStatusTest.php | 161 +++++++++ .../PrivilegedDatabaseAccountsTest.php | 161 +++++++++ .../scenarios/database-credential-guard.yaml | 81 +++++ .../scenarios/opcache-status-visibility.yaml | 72 ++++ .../settings-system-info-probe-isolation.yaml | 8 +- 38 files changed, 2402 insertions(+), 56 deletions(-) create mode 100644 app/Http/Middleware/Concerns/DetectsRequestLocale.php create mode 100644 app/Http/Middleware/DatabaseCredentialGuard.php create mode 100644 app/Support/OpcacheStatus.php create mode 100644 app/Support/PrivilegedDatabaseAccounts.php create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/database_credential.php create mode 100644 lang/en/database_credential.php create mode 100644 lang/ko/database_credential.php create mode 100644 resources/views/database-credential-error.blade.php create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-info-opcache.test.tsx create mode 100644 tests/Feature/Middleware/DatabaseCredentialGuardTest.php create mode 100644 tests/Playwright/specs/admin/settings-info-opcache.spec.ts create mode 100644 tests/Unit/Installer/PrivilegedDbAccountGuardTest.php create mode 100644 tests/Unit/Support/OpcacheStatusTest.php create mode 100644 tests/Unit/Support/PrivilegedDatabaseAccountsTest.php create mode 100644 tests/scenarios/database-credential-guard.yaml create mode 100644 tests/scenarios/opcache-status-visibility.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index eeede335..7208e4cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,12 @@ - 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다. - 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.) +- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다. +- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다. + ### Fixed +- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다. - SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다. ## [7.0.5] - 2026-07-16 diff --git a/app/Http/Middleware/Concerns/DetectsRequestLocale.php b/app/Http/Middleware/Concerns/DetectsRequestLocale.php new file mode 100644 index 00000000..58ef71f5 --- /dev/null +++ b/app/Http/Middleware/Concerns/DetectsRequestLocale.php @@ -0,0 +1,42 @@ +header('Accept-Language'); + if ($acceptLanguage) { + $languages = explode(',', $acceptLanguage); + foreach ($languages as $language) { + $lang = trim(explode(';', $language)[0]); + if (strpos($lang, '-') !== false) { + $lang = explode('-', $lang)[0]; + } + if (in_array($lang, $supportedLocales)) { + return $lang; + } + } + } + + return config('app.locale', 'ko'); + } +} diff --git a/app/Http/Middleware/DatabaseCredentialGuard.php b/app/Http/Middleware/DatabaseCredentialGuard.php new file mode 100644 index 00000000..26aa69de --- /dev/null +++ b/app/Http/Middleware/DatabaseCredentialGuard.php @@ -0,0 +1,85 @@ +resolveUsername(); + + if (PrivilegedDatabaseAccounts::isUsable($username)) { + return $next($request); + } + + // SetLocale 미실행 상태이므로 로케일을 자체 감지한다. + // API/HTML 분기보다 먼저 적용해야 JSON 메시지도 활성 로케일을 반영한다. + app()->setLocale($this->detectLocale($request)); + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'success' => false, + 'message' => __('database_credential.message'), + ], 503); + } + + // 확장·템플릿이 로딩되지 않은 상태이므로 템플릿 뷰나 errors/layout 을 + // 쓸 수 없다. DB/JS 무의존 자체 완결 정적 페이지를 반환한다. + return response()->view('database-credential-error', [ + // 실제 계정명은 전달하지 않는다 — 미인증 공개 페이지이므로 정보 유출. + 'reason' => PrivilegedDatabaseAccounts::isBlocked($username) ? 'blocked' : 'empty', + ], 503); + } + + /** + * 현재 DB 커넥션 설정에서 사용자명을 해석합니다. + * + * DB::connection() 을 거치지 않고 config 배열만 읽습니다. 이 가드는 DB 설정이 + * 잘못된 상황에서 동작해야 하므로, 판정을 위해 커넥션을 해석하는 것 자체가 + * 새로운 실패 지점이 됩니다. read/write 우선순위 규칙은 + * PrivilegedDatabaseAccounts 가 CoreServiceProvider 와 공유합니다. + * + * @return string|null 해석된 사용자명 (없으면 null) + */ + protected function resolveUsername(): ?string + { + $connection = config('database.default'); + + return PrivilegedDatabaseAccounts::resolveUsername( + config("database.connections.{$connection}", []) + ); + } +} diff --git a/app/Http/Middleware/MaintenanceModePage.php b/app/Http/Middleware/MaintenanceModePage.php index 3c036c80..b183dd58 100644 --- a/app/Http/Middleware/MaintenanceModePage.php +++ b/app/Http/Middleware/MaintenanceModePage.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Http\Middleware\Concerns\DetectsRequestLocale; use Closure; use Illuminate\Foundation\Http\MaintenanceModeBypassCookie; use Illuminate\Http\Request; @@ -9,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response; class MaintenanceModePage { + use DetectsRequestLocale; + /** * Maintenance 모드일 때 전용 정적 페이지를 렌더링합니다. * @@ -76,31 +79,4 @@ class MaintenanceModePage return MaintenanceModeBypassCookie::isValid($cookie, $data['secret']); } - - /** - * 로케일을 감지합니다. SetLocale 미들웨어의 로직을 준용합니다. - * - * @param Request $request HTTP 요청 - * @return string 감지된 로케일 - */ - protected function detectLocale(Request $request): string - { - $supportedLocales = config('app.supported_locales', ['ko', 'en']); - - $acceptLanguage = $request->header('Accept-Language'); - if ($acceptLanguage) { - $languages = explode(',', $acceptLanguage); - foreach ($languages as $language) { - $lang = trim(explode(';', $language)[0]); - if (strpos($lang, '-') !== false) { - $lang = explode('-', $lang)[0]; - } - if (in_array($lang, $supportedLocales)) { - return $lang; - } - } - } - - return config('app.locale', 'ko'); - } } diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index d827d885..c63e4bb0 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -92,6 +92,7 @@ use App\Services\DriverRegistryService; use App\Services\LayoutExtensionService; use App\Services\TemplateLayoutAttachmentService; use App\Services\UniqueIdService; +use App\Support\PrivilegedDatabaseAccounts; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -338,9 +339,9 @@ class CoreServiceProvider extends ServiceProvider return; } - // DB 연결 유효성 검증 (root 사용자 접속 방지) + // DB 연결 유효성 검증 (최고권한 계정 접속 방지) if (! $this->isDatabaseConnectionValid()) { - Log::error('Database connection invalid: using root user or missing credentials. Skipping extension loading.'); + Log::error('Database connection invalid: using a privileged database account or missing credentials. Skipping extension loading.'); return; } @@ -444,30 +445,20 @@ class CoreServiceProvider extends ServiceProvider /** * 데이터베이스 연결이 유효한지 검증합니다. * - * root 사용자로 접속하거나 비밀번호가 없는 경우를 감지하여 + * DB 최고권한 계정으로 접속하거나 사용자명이 비어 있는 경우를 감지하여 * 잘못된 설정으로 인한 접속 오류를 방지합니다. + * 계정 판정은 `PrivilegedDatabaseAccounts` 가 SSoT 입니다. * * @return bool 연결이 유효하면 true */ protected function isDatabaseConnectionValid(): bool { try { - $config = DB::connection()->getConfig(); + // read/write 분리 설정의 우선순위 규칙까지 PrivilegedDatabaseAccounts 가 소유한다. + // 호출처마다 규칙이 달라지면 같은 설정에 서로 다른 판정이 나온다. + $username = PrivilegedDatabaseAccounts::resolveUsername(DB::connection()->getConfig()); - // read/write 분리 설정인 경우 read 설정 확인 - $username = $config['username'] ?? null; - - // read 설정이 배열로 있는 경우 - if (isset($config['read']['username'])) { - $username = $config['read']['username']; - } - - // root 사용자 또는 빈 username인 경우 무효 - if (empty($username) || $username === 'root') { - return false; - } - - return true; + return PrivilegedDatabaseAccounts::isUsable($username); } catch (\Throwable $e) { Log::error('Database configuration check failed: '.$e->getMessage()); diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 7bbc9a72..080aa70c 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -8,6 +8,7 @@ use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\HookManager; use App\Http\Resources\AttachmentResource; use App\Support\ConfigCacheHelper; +use App\Support\OpcacheStatus; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -844,6 +845,9 @@ class SettingsService 'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')), 'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')), 'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')), + // 판정은 App\Support\OpcacheStatus 가 SSoT — 인스톨러 요구사항 화면과 같은 답을 낸다. + // enabled 가 null 이면 "확인 불가"(ini_get 차단 환경). + 'opcache' => $this->safeSystemProbe('opcache', fn () => $this->getOpcacheStatus(), ['loaded' => false, 'enabled' => null]), 'install_path' => base_path(), 'config_path' => storage_path('app/settings'), 'log_path' => storage_path('logs'), @@ -854,6 +858,19 @@ class SettingsService ]; } + /** + * OPcache 활성화 상태를 조회합니다. + * + * 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 인스톨러 요구사항 화면과 + * 같은 답을 냅니다. `enabled` 가 null 이면 ini_get 이 차단된 "확인 불가" 상태입니다. + * + * @return array{loaded: bool, enabled: bool|null} OPcache 상태 + */ + protected function getOpcacheStatus(): array + { + return OpcacheStatus::probe(); + } + /** * 시스템 정보 probe 를 안전하게 실행합니다. * diff --git a/app/Support/OpcacheStatus.php b/app/Support/OpcacheStatus.php new file mode 100644 index 00000000..6ca33d7e --- /dev/null +++ b/app/Support/OpcacheStatus.php @@ -0,0 +1,59 @@ + extension_loaded($name); + $iniGet ??= static fn (string $key) => ini_get($key); + + if (! $extensionLoaded(self::EXTENSION_NAME)) { + return ['loaded' => false, 'enabled' => false]; + } + + try { + $raw = $iniGet(self::ENABLE_DIRECTIVE); + } catch (\Throwable) { + // ini_get 이 disable_functions 로 차단된 환경 — 확인 불가 + return ['loaded' => true, 'enabled' => null]; + } + + // ini_get 은 지시자를 못 읽으면 false 를 반환한다 (값 '0' 과 구분해야 함) + if ($raw === false || $raw === null || $raw === '') { + return ['loaded' => true, 'enabled' => null]; + } + + return ['loaded' => true, 'enabled' => filter_var($raw, FILTER_VALIDATE_BOOLEAN)]; + } +} diff --git a/app/Support/PrivilegedDatabaseAccounts.php b/app/Support/PrivilegedDatabaseAccounts.php new file mode 100644 index 00000000..03eb4d72 --- /dev/null +++ b/app/Support/PrivilegedDatabaseAccounts.php @@ -0,0 +1,90 @@ +remove(PreventRequestsDuringMaintenance::class); + // DB 자격증명 가드 (인증 불필요) — 최고권한 계정/빈 사용자명으로 인한 + // 확장 로딩 스킵을 전용 에러 페이지로 드러낸다. + // + // prepend() 는 새 항목을 기존 prepends 앞에 넣으므로, 아래 MaintenanceModePage + // prepend 가 뒤따르면 최종 순서는 [MaintenanceModePage, DatabaseCredentialGuard] 가 + // 된다. 점검 중에는 점검 페이지가 우선해야 하므로 이 호출 순서를 유지한다. + $middleware->prepend(DatabaseCredentialGuard::class); + // Maintenance 모드 전용 페이지 미들웨어 (인증 불필요, 최우선 실행) $middleware->prepend(MaintenanceModePage::class); diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 0c15426f..ae1f9f6d 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 데이터베이스 계정 설정 오류 안내 화면의 문구 일본어 번역 추가 (`database_credential.*`) — 최고 권한 계정을 사용하거나 사용자명이 비어 있을 때 표시되는 안내와 복구 방법이 일본어 로케일에서 자연스럽게 표시됩니다. - 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/database_credential.php b/lang-packs/_bundled/g7-core-ja/backend/ja/database_credential.php new file mode 100644 index 00000000..2fb617b8 --- /dev/null +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/database_credential.php @@ -0,0 +1,10 @@ + 'データベースアカウント設定エラー', + 'message' => 'データベースアカウント設定に問題があり、サイトを表示できません。', + 'description' => 'この状態ではモジュール·プラグイン·テンプレートがロードされません。以下のガイドに従って設定を修正してください。', + 'blocked_reason' => 'root のようなデータベース最高権限アカウントはセキュリティ上使用できません。該当するアカウントが流出した場合、データベース全体が危険にさらされます。', + 'empty_reason' => 'データベースユーザー名が空です。設定が欠落しているか、破損している可能性があります。', + 'recovery_guide' => '.env ファイルの DB_WRITE_USERNAME 値をこのサイト専用のデータベースアカウントに変更してから、php artisan config:clear コマンドを実行してください。', +]; diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index cd91fbd1..2f92707e 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 환경설정 > 정보 화면에 추가된 OPcache 상태 항목과 비활성 안내 문구 일본어 번역 추가 — 상태 표시와 성능 저하 안내가 일본어 로케일에서도 올바르게 표시됩니다. - 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index d38b1491..5e8c9732 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1909,7 +1909,12 @@ "update_guide_cmd": "$ sudo php artisan core:update", "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted", "upgrade_guide_link_desc": "運用環境別の権限/ユーザーグループ別の詳細な手順はアップグレードガイドを参照してください。", - "upgrade_guide_link_label": "全体アップグレードガイドを表示" + "upgrade_guide_link_label": "全体アップグレードガイドを表示", + "opcache": "OPcache", + "opcache_enabled": "有効化", + "opcache_disabled": "無効化", + "opcache_unknown": "確認不可", + "opcache_disabled_warning": "OPcacheが無効化されています。毎回のリクエストごとにPHPコードが再解析されるため、サイトの応答速度が著しく低下します。php.iniでopcache.enable=1の設定をお勧めします。設定変更後、この画面に反映されるまで最大1時間かかる可能性があります。キャッシュクリアを実行すると即座に反映されます。" }, "modals": { "password_confirm_title": "パスワード確認", diff --git a/lang/en/database_credential.php b/lang/en/database_credential.php new file mode 100644 index 00000000..54fe43e9 --- /dev/null +++ b/lang/en/database_credential.php @@ -0,0 +1,10 @@ + 'Database Account Configuration Error', + 'message' => 'The site cannot be displayed because of a problem with the database account configuration.', + 'description' => 'While this problem persists, modules, plugins and templates are not loaded. Please correct the configuration using the guidance below.', + 'blocked_reason' => 'Database superuser accounts such as root cannot be used, for security reasons. If such an account is compromised, the entire database is at risk.', + 'empty_reason' => 'The database username is empty. The configuration may be missing or corrupted.', + 'recovery_guide' => 'Change DB_WRITE_USERNAME in the .env file to a database account dedicated to this site, then run: php artisan config:clear', +]; diff --git a/lang/ko/database_credential.php b/lang/ko/database_credential.php new file mode 100644 index 00000000..3c21badc --- /dev/null +++ b/lang/ko/database_credential.php @@ -0,0 +1,10 @@ + '데이터베이스 계정 설정 오류', + 'message' => '데이터베이스 계정 설정에 문제가 있어 사이트를 표시할 수 없습니다.', + 'description' => '이 상태에서는 모듈·플러그인·템플릿이 로딩되지 않습니다. 아래 안내에 따라 설정을 수정해 주세요.', + 'blocked_reason' => 'root 같은 데이터베이스 최고 권한 계정은 보안상 사용할 수 없습니다. 해당 계정이 유출되면 데이터베이스 전체가 위험해집니다.', + 'empty_reason' => '데이터베이스 사용자명이 비어 있습니다. 설정이 누락되었거나 손상되었을 수 있습니다.', + 'recovery_guide' => '.env 파일의 DB_WRITE_USERNAME 값을 이 사이트 전용 데이터베이스 계정으로 변경한 뒤, php artisan config:clear 명령을 실행해 주세요.', +]; diff --git a/public/install/api/check-configuration.php b/public/install/api/check-configuration.php index fbcc848e..4b902a72 100644 --- a/public/install/api/check-configuration.php +++ b/public/install/api/check-configuration.php @@ -69,6 +69,7 @@ class ValidationApi 'directories' => $this->checkDirectoryPermissions(), 'required_files' => $this->checkRequiredFiles(), 'https' => $this->checkHttps(), + 'opcache' => $this->checkOpcache(), ]; // 모든 필수 요구사항 통과 여부 @@ -137,6 +138,15 @@ class ValidationApi throw new Exception(lang('error_db_credentials_required')); } + // DB 최고권한 계정 차단 (Write/Read 공통). + // 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로 + // 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT. + if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) { + throw new Exception( + lang('error_db_username_privileged', ['username' => $username]) + ); + } + // DB 테이블 접두사 길이 검증 (Write DB 입력에만 적용) // 접두사가 길면 자동 생성 인덱스명이 MySQL identifier 한도(64자)를 초과해 // 일부 확장 설치 시 마이그레이션이 실패하므로 연결 시도 전에 차단한다. @@ -505,6 +515,35 @@ class ValidationApi ]; } + /** + * OPcache 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음) + * + * 확장 로드 여부만으로는 부족하다. `Zend OPcache` 가 로드돼 있어도 + * `opcache.enable=0` 이면 실제로는 동작하지 않으므로 지시자까지 확인한다. + * 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 코어 관리자 화면과 같은 답을 낸다. + * + * `enabled` 가 null 이면 "확인 불가"(ini_get 차단 환경)로, 경고도 차단도 하지 않는다. + */ + private function checkOpcache(): array + { + $status = \App\Support\OpcacheStatus::probe(); + + if ($status['enabled'] === null) { + $message = lang('opcache_unknown'); + } else { + $message = $status['enabled'] + ? lang('opcache_enabled') + : lang('opcache_disabled_warning'); + } + + return [ + 'required' => false, // OPcache는 선택 사항 (성능 권장) + 'loaded' => $status['loaded'], + 'enabled' => $status['enabled'], + 'message' => $message, + ]; + } + /** * 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증 */ @@ -1026,7 +1065,7 @@ class ValidationApi return false; } - // HTTPS는 선택 사항이므로 검증하지 않음 + // HTTPS / OPcache 는 선택 사항이므로 검증하지 않음 (경고만 표시하고 설치는 진행) return true; } diff --git a/public/install/assets/js/installer.js b/public/install/assets/js/installer.js index f941daba..7ac1a7d0 100644 --- a/public/install/assets/js/installer.js +++ b/public/install/assets/js/installer.js @@ -443,6 +443,31 @@ ); } + // 9. OPcache 카드 (성능 권장, 필수 아님) + // failedRequirements.push() 를 하지 않는다 — 비활성이어도 설치는 진행된다. + if (data.opcache) { + let opcacheStatusClass, opcacheStatusText; + if (data.opcache.enabled === null) { + // ini_get 이 차단된 환경 — 확인 불가 (경고도 차단도 아님) + opcacheStatusClass = 'status-warning'; + opcacheStatusText = lang('opcache_unknown'); + } else if (data.opcache.enabled) { + opcacheStatusClass = 'status-pass'; + opcacheStatusText = lang('enabled'); + } else { + // 단순히 '비활성화됨' 만 보이면 경고의 실질(성능 저하)이 전달되지 않는다. + opcacheStatusClass = 'status-warning'; + opcacheStatusText = lang('opcache_disabled_short'); + } + html += renderSingleItemCard( + lang('opcache'), + opcacheStatusClass, + opcacheStatusText, + '', + false + ); + } + html += ''; // 필수 조건 미충족 시 에러 박스 표시 @@ -1387,6 +1412,39 @@ FormValidator.setFieldValid(field.id, true); } + /** + * DB 최고권한 계정명 여부 판정 + * + * 목록은 서버(App\Support\PrivilegedDatabaseAccounts::BLOCKED)에서 내려온 값을 + * 그대로 쓴다. JS 에 목록을 중복 정의하면 서버와 어긋날 수 있다. + * 비교는 서버와 동일하게 소문자·트림 기준. + * + * @param {string} username 검사할 DB 사용자명 + * @returns {boolean} 최고권한 계정이면 true + */ + function isPrivilegedDbAccount(username) { + var blocked = window.INSTALLER_BLOCKED_DB_ACCOUNTS; + + // 서버 목록이 없으면 클라이언트 판정을 포기한다 (서버 검증이 최종 방어선). + if (!Array.isArray(blocked)) { + return false; + } + + return blocked.indexOf(String(username || '').trim().toLowerCase()) !== -1; + } + + /** + * 최고권한 계정 차단 메시지를 생성한다. + * + * JS 의 lang() 은 서버 lang() 과 달리 치환을 지원하지 않으므로 호출부에서 수동 치환한다. + * + * @param {string} username 입력된 DB 사용자명 + * @returns {string} 치환된 메시지 + */ + function privilegedDbAccountMessage(username) { + return getLangMessage('error_db_username_privileged').replace(':username', username); + } + /** * 필드 검증 */ @@ -1438,6 +1496,14 @@ return false; } + // DB 사용자명 검증 — 최고권한 계정은 보안상 사용할 수 없다. + if ((fieldName === 'db_write_username' || fieldName === 'db_read_username') && value) { + if (isPrivilegedDbAccount(value)) { + showFieldError(field, privilegedDbAccountMessage(value)); + return false; + } + } + // DB Prefix 검증 (선택 사항이지만, 입력 시 형식 검증) if (fieldName === 'db_prefix' && value) { // 영문 소문자로 시작해야 함 @@ -1639,6 +1705,11 @@ } if (!dbWriteUsername || !dbWriteUsername.value.trim()) { errors.push({ field: dbWriteUsername, message: lang('error_db_username_required') }); + } else if (isPrivilegedDbAccount(dbWriteUsername.value)) { + errors.push({ + field: dbWriteUsername, + message: privilegedDbAccountMessage(dbWriteUsername.value.trim()), + }); } // Read DB 사용 시 Read DB 필드 확인 @@ -1656,6 +1727,11 @@ } if (!dbReadUsername || !dbReadUsername.value.trim()) { errors.push({ field: dbReadUsername, message: lang('error_db_username_required') }); + } else if (isPrivilegedDbAccount(dbReadUsername.value)) { + errors.push({ + field: dbReadUsername, + message: privilegedDbAccountMessage(dbReadUsername.value.trim()), + }); } } diff --git a/public/install/includes/config.php b/public/install/includes/config.php index ccc7b62a..1fce7127 100644 --- a/public/install/includes/config.php +++ b/public/install/includes/config.php @@ -210,4 +210,17 @@ if (!defined('SUPPORTED_LANGUAGES')) { // 설치 완료 후 인스톨러 파일 삭제 여부 if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) { define('DELETE_INSTALLER_AFTER_COMPLETE', true); +} + +// 코어와 공유하는 판정 SSoT 클래스 로드. +// +// 인스톨러는 Laravel 오토로드 없이 도는 순수 PHP 이므로 파일을 직접 require 한다. +// 두 클래스는 프레임워크 의존성이 0 이라 이 방식이 성립하며, 인스톨러와 코어가 +// 서로 다른 판정을 내리는 것을 구조적으로 차단한다. +// class_exists 가드: Laravel 부트가 선행된 테스트 환경에서 중복 로드 방지. +if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts', false)) { + require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php'; +} +if (!class_exists('App\\Support\\OpcacheStatus', false)) { + require_once BASE_PATH . '/app/Support/OpcacheStatus.php'; } \ No newline at end of file diff --git a/public/install/includes/request-handler.php b/public/install/includes/request-handler.php index f28ab5e6..720def4e 100644 --- a/public/install/includes/request-handler.php +++ b/public/install/includes/request-handler.php @@ -119,6 +119,32 @@ function handleStep2Post(): void } } +/** + * DB 사용자명을 검증해 에러 배열에 결과를 기록합니다. + * + * 빈 값(설정 누락)과 DB 최고권한 계정을 모두 거부합니다. 최고권한 계정 자격증명이 + * 유출되면 데이터베이스 전체가 위험해지므로 설치 단계에서 차단합니다. + * 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT 입니다. + * + * @param string $username 입력된 DB 사용자명 + * @param string $field 에러 배열에 사용할 필드명 (db_write_username 등) + * @param array &$errors 에러 배열 (참조) + */ +function validateDbUsername(string $username, string $field, array &$errors): void +{ + $username = trim($username); + + if ($username === '') { + $errors[$field] = lang('error_db_username_required'); + + return; + } + + if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) { + $errors[$field] = lang('error_db_username_privileged', ['username' => $username]); + } +} + /** * Step 3 POST 처리 (데이터베이스 및 사이트 설정) * @@ -140,6 +166,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): if (empty($formData['db_write_database'])) { $errors['db_write_database'] = lang('error_db_name_required'); } + validateDbUsername($formData['db_write_username'] ?? '', 'db_write_username', $errors); // Read DB 검증 (사용하는 경우) if (! empty($formData['use_read_db'])) { @@ -149,6 +176,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): if (empty($formData['db_read_database'])) { $errors['db_read_database'] = lang('error_db_name_required'); } + validateDbUsername($formData['db_read_username'] ?? '', 'db_read_username', $errors); } // 관리자 정보 검증 diff --git a/public/install/index.php b/public/install/index.php index 63a01683..fd59d26d 100644 --- a/public/install/index.php +++ b/public/install/index.php @@ -171,6 +171,9 @@ $stepFile = __DIR__.'/views/'.$currentStep.'-'.(STEP_FILE_MAP[$currentStep] ?? ' window.CURRENT_STEP = ; window.INSTALLER_LANG = ; window.INSTALLER_STATE_LOCALE = ; + // DB 최고권한 계정 목록 — 서버 상수를 그대로 내려보내 JS 에 목록을 중복 정의하지 않는다. + // 클라이언트 검증은 즉시 피드백용이며, 실제 차단은 서버 3개 경로가 담당한다. + window.INSTALLER_BLOCKED_DB_ACCOUNTS = ; diff --git a/public/install/lang/en.php b/public/install/lang/en.php index e6c16889..f6142eda 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -52,6 +52,7 @@ return [ 'directory_permissions' => 'Directory Permissions', 'disk_space' => 'Disk Space', 'https' => 'HTTPS', + 'opcache' => 'OPcache', 'required' => 'Required', 'enabled' => 'Enabled', 'not_enabled' => 'Not Enabled', @@ -171,6 +172,7 @@ return [ 'error_db_name_required' => 'Database name is required.', 'error_db_username_required' => 'Database username is required.', 'error_db_credentials_required' => 'Database name and username are required.', + 'error_db_username_privileged' => 'For security reasons, G7 cannot be installed with the ":username" account. If a superuser account is compromised, the entire database is at risk. Please create a dedicated database user for G7, grant it only the privileges it needs, and enter that account instead.', 'error_db_prefix_too_long' => 'Table prefix may be up to :max characters. (entered: :current) A long prefix makes some table index names exceed the database identifier limit.', 'error_db_privileges_insufficient' => 'Insufficient database privileges.', 'error_db_not_tested' => 'Please test the database connection first.', @@ -233,6 +235,12 @@ return [ 'https_enabled' => 'HTTPS is enabled (recommended)', 'https_disabled' => 'HTTPS is disabled. We recommend using HTTPS for security.', + // OPcache Messages + 'opcache_enabled' => 'OPcache is enabled (recommended)', + 'opcache_disabled_short' => 'Disabled — slower responses', + 'opcache_disabled_warning' => 'OPcache is disabled. PHP code is re-parsed on every request, which noticeably slows down site response times. We recommend setting opcache.enable=1 in php.ini.', + 'opcache_unknown' => 'OPcache status could not be determined. Reading PHP settings is restricted on this server.', + // API Response Messages 'api_method_not_allowed' => 'Only POST requests are allowed.', 'api_invalid_request' => 'Invalid request data.', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index 1823e000..a6aeed73 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -52,6 +52,7 @@ return [ 'directory_permissions' => '디렉토리 권한', 'disk_space' => '디스크 공간', 'https' => 'HTTPS', + 'opcache' => 'OPcache', 'required' => '필수', 'enabled' => '활성화됨', 'not_enabled' => '비활성화됨', @@ -171,6 +172,7 @@ return [ 'error_db_name_required' => '데이터베이스 이름은 필수입니다.', 'error_db_username_required' => '데이터베이스 사용자명을 입력해주세요.', 'error_db_credentials_required' => '데이터베이스명과 사용자명은 필수입니다.', + 'error_db_username_privileged' => '보안상 :username 계정으로는 설치할 수 없습니다. 최고 권한 계정이 유출되면 데이터베이스 전체가 위험해집니다. G7 전용 데이터베이스 사용자를 새로 만들어 필요한 권한만 부여한 뒤 입력해주세요.', 'error_db_prefix_too_long' => '테이블 접두사는 최대 :max자까지 가능합니다. (입력: :current자) 접두사가 길면 일부 테이블의 인덱스명이 데이터베이스 한도를 초과합니다.', 'error_db_privileges_insufficient' => '데이터베이스에 필요한 권한이 부족합니다.', 'error_db_not_tested' => '데이터베이스 연결 테스트를 먼저 수행해주세요.', @@ -233,6 +235,12 @@ return [ 'https_enabled' => 'HTTPS가 활성화되어 있습니다. (권장)', 'https_disabled' => 'HTTPS가 비활성화되어 있습니다. 보안을 위해 HTTPS 사용을 권장합니다.', + // OPcache 메시지 + 'opcache_enabled' => 'OPcache가 활성화되어 있습니다. (권장)', + 'opcache_disabled_short' => '비활성화됨 — 응답 속도 저하', + 'opcache_disabled_warning' => 'OPcache가 비활성화되어 있습니다. 매 요청마다 PHP 코드를 다시 해석하므로 사이트 응답 속도가 눈에 띄게 느려집니다. php.ini에서 opcache.enable=1 설정을 권장합니다.', + 'opcache_unknown' => 'OPcache 상태를 확인할 수 없습니다. 서버에서 설정 조회가 제한되어 있습니다.', + // API 응답 메시지 'api_method_not_allowed' => 'POST 요청만 허용됩니다.', 'api_invalid_request' => '잘못된 요청 데이터입니다.', diff --git a/public/install/views/3-configuration.php b/public/install/views/3-configuration.php index 86434ad1..087e903e 100644 --- a/public/install/views/3-configuration.php +++ b/public/install/views/3-configuration.php @@ -84,8 +84,12 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
- + +
@@ -137,8 +141,12 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
- + +
diff --git a/resources/views/database-credential-error.blade.php b/resources/views/database-credential-error.blade.php new file mode 100644 index 00000000..aad2b46f --- /dev/null +++ b/resources/views/database-credential-error.blade.php @@ -0,0 +1,137 @@ + + + + + + {{ config('app.name', '그누보드7') }} - {{ __('database_credential.title') }} + + + +
+
+ {{-- Warning triangle icon (inline SVG - no external dependency) --}} +
+ + + +
+ +

{{ __('database_credential.title') }}

+

{{ __('database_credential.message') }}

+

{{ __('database_credential.description') }}

+ + {{-- 원인 구분 안내. 현재 설정된 계정명 자체는 출력하지 않는다 --}} + {{-- (미인증 공개 페이지이므로 DB 계정명 노출은 정보 유출). --}} +

+ {{ __('database_credential.'.($reason ?? 'blocked').'_reason') }} +

+ +

{{ __('database_credential.recovery_guide') }}

+
+
+ + diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 53a63d5d..e32e88ff 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -11,6 +11,7 @@ - SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다. - SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다. - 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. +- 환경설정 > 정보의 시스템 사양에 OPcache 상태를 추가했습니다. 꺼져 있으면 사이트 응답 속도가 느려진다는 안내와 설정 방법을 함께 표시합니다. ### Added diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-info-opcache.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-info-opcache.test.tsx new file mode 100644 index 00000000..705371cf --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-info-opcache.test.tsx @@ -0,0 +1,314 @@ +/** + * @file admin-settings-info-opcache.test.tsx + * @description 환경설정 > 정보 탭 OPcache 상태 행/경고 블록 렌더링 테스트 + * + * 테스트 대상 (_tab_info.json 의 OPcache 블록): + * - opcache.enabled: true → 상태 "활성화됨", 경고 블록 미표시 + * - opcache.enabled: false → 상태 "비활성화됨", 경고 블록 표시 (성능 저하 안내) + * - opcache.enabled: null → 상태 "확인 불가", 경고 블록 미표시 (확인 불가는 경고 아님) + * - opcache 키 자체 부재 → 상태 "확인 불가", 경고 블록 미표시 (구버전 응답 방어) + * + * 경고 블록의 조건은 `=== false` 엄격 비교여야 한다. `!enabled` 로 쓰면 + * null(확인 불가)에서도 "비활성화됨" 경고가 잘못 뜬다. + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +const TestDiv: React.FC<{ + className?: string; + children?: React.ReactNode; + 'data-testid'?: string; +}> = ({ className, children, 'data-testid': testId }) => ( +
{children}
+); + +const TestSpan: React.FC<{ + className?: string; + children?: React.ReactNode; + text?: string; + 'data-testid'?: string; +}> = ({ className, children, text, 'data-testid': testId }) => ( + {children || text} +); + +const TestP: React.FC<{ + className?: string; + children?: React.ReactNode; + text?: string; + 'data-testid'?: string; +}> = ({ className, children, text, 'data-testid': testId }) => ( +

{children || text}

+); + +const TestIcon: React.FC<{ + name?: string; + className?: string; +}> = ({ name, className }) => ( + +); + +// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다. +// 미등록 시 트리 전체가 렌더되지 않아 "없어야 한다" 계열 단언이 거짓 통과한다. +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + P: { component: TestP, metadata: { name: 'P', type: 'basic' } }, + Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + + return registry; +} + +/** + * system-info API 응답 생성. + * + * @param opcache opcache 페이로드 (undefined 면 키 자체를 생략) + */ +function createSystemInfoResponse(opcache?: { loaded: boolean; enabled: boolean | null }) { + return { + success: true, + data: { + php_memory_limit: '512M', + max_execution_time: '30초', + ...(opcache === undefined ? {} : { opcache }), + }, + }; +} + +// _tab_info.json 의 OPcache 블록과 동일한 표현식. +// 프로덕션에서는 $t:key 로 라벨을 해석하나, 테스트 레지스트리에는 i18n 이 없으므로 +// 판정 결과 자체를 문자열 리터럴로 확인한다. +const STATUS_EXPR = + "{{systemInfo?.data?.opcache?.enabled === true ? 'enabled' " + + ": (systemInfo?.data?.opcache?.enabled === false ? 'disabled' : 'unknown')}}"; +const WARNING_IF = '{{systemInfo?.data?.opcache?.enabled === false}}'; + +const infoLayout = { + version: '1.0.0', + layout_name: 'test_admin_settings_info', + data_sources: [ + { + id: 'systemInfo', + type: 'api', + endpoint: '/api/admin/settings/system-info', + method: 'GET', + auto_fetch: true, + auth_required: true, + fallback: { data: {} }, + }, + ], + components: [ + { + id: 'card_system_specs', + type: 'basic', + name: 'Div', + children: [ + { + type: 'basic', + name: 'Div', + props: { className: 'flex-between' }, + children: [ + { + type: 'basic', + name: 'Span', + props: { className: 'text-label', 'data-testid': 'opcache-label' }, + text: 'OPcache', + }, + { + type: 'basic', + name: 'Span', + props: { className: 'text-heading', 'data-testid': 'opcache-value' }, + text: STATUS_EXPR, + }, + ], + }, + { + if: WARNING_IF, + type: 'basic', + name: 'Div', + props: { className: 'alert-warning', 'data-testid': 'opcache-warning' }, + children: [ + { + type: 'basic', + name: 'Div', + props: { className: 'flex-center gap-3' }, + children: [ + { + type: 'basic', + name: 'Icon', + props: { + name: 'triangle-exclamation', + className: 'text-xl text-amber-600 dark:text-amber-400 flex-shrink-0', + }, + }, + { + type: 'basic', + name: 'P', + props: { className: 'text-warning-soft' }, + text: 'OPcache가 비활성화되어 있습니다.', + }, + ], + }, + ], + }, + ], + }, + ], +}; + +describe('admin-settings 정보 탭 — OPcache 상태', () => { + let registry: ComponentRegistry; + + beforeEach(() => { + registry = setupTestRegistry(); + }); + + afterEach(() => { + (registry as any).registry = {}; + }); + + describe('OPcache 활성 (enabled: true)', () => { + // @scenario opcache_state=enabled, surface=admin_info_row + // @effects admin_info_row_shows_enabled_disabled_unknown + it('상태가 활성화됨으로 표시되어야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: true }), + }); + + await testUtils.render(); + + expect(screen.getByTestId('opcache-value').textContent).toBe('enabled'); + + testUtils.cleanup(); + }); + + it('경고 블록이 표시되지 않아야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: true }), + }); + + await testUtils.render(); + + // 긍정 앵커 — 트리가 실제로 렌더됐음을 먼저 확인해야 아래 부정 단언이 의미를 갖는다. + expect(screen.getByTestId('opcache-label')).toBeTruthy(); + expect(screen.queryByTestId('opcache-warning')).toBeNull(); + + testUtils.cleanup(); + }); + }); + + describe('OPcache 비활성 (enabled: false)', () => { + // @scenario opcache_state=loaded_but_disabled, surface=admin_info_row + // @effects admin_info_row_shows_enabled_disabled_unknown + it('상태가 비활성화됨으로 표시되어야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: false }), + }); + + await testUtils.render(); + + expect(screen.getByTestId('opcache-value').textContent).toBe('disabled'); + + testUtils.cleanup(); + }); + + // @scenario opcache_state=loaded_but_disabled, surface=admin_info_warning_block + // @effects admin_info_warning_shown_only_when_disabled + it('성능 저하 경고 블록이 표시되어야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: false }), + }); + + await testUtils.render(); + + const warning = screen.getByTestId('opcache-warning'); + expect(warning).toBeTruthy(); + expect(warning.className).toContain('alert-warning'); + expect(screen.getByTestId('icon-triangle-exclamation')).toBeTruthy(); + + testUtils.cleanup(); + }); + + it('확장이 로드돼 있어도 enabled=false 면 경고해야 한다', async () => { + // 확장 로드 여부만으로 판정하는 회귀 차단 (opcache.enable=0 케이스) + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: false }), + }); + + await testUtils.render(); + + expect(screen.getByTestId('opcache-warning')).toBeTruthy(); + + testUtils.cleanup(); + }); + }); + + describe('OPcache 확인 불가 (enabled: null)', () => { + // @scenario opcache_state=ini_get_blocked, surface=admin_info_row + // @effects admin_info_row_shows_enabled_disabled_unknown + it('상태가 확인 불가로 표시되어야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: null }), + }); + + await testUtils.render(); + + expect(screen.getByTestId('opcache-value').textContent).toBe('unknown'); + + testUtils.cleanup(); + }); + + // @scenario opcache_state=ini_get_blocked, surface=admin_info_warning_block + // @effects admin_info_warning_hidden_when_unknown + it('경고 블록이 표시되지 않아야 한다 (확인 불가는 경고가 아니다)', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse({ loaded: true, enabled: null }), + }); + + await testUtils.render(); + + // 긍정 앵커 — 미렌더로 인한 거짓 통과 차단 + expect(screen.getByTestId('opcache-label')).toBeTruthy(); + expect(screen.queryByTestId('opcache-warning')).toBeNull(); + + testUtils.cleanup(); + }); + }); + + describe('opcache 키 부재 (구버전 응답 방어)', () => { + /** + * opcache 키가 아예 없는 구버전/폴백 응답에서도 화면이 깨지지 않아야 한다. + * + * @effects admin_info_defensive_against_missing_opcache_key + */ + it('상태가 확인 불가로 표시되고 경고 블록이 뜨지 않아야 한다', async () => { + const testUtils = createLayoutTest(infoLayout); + testUtils.mockApi('systemInfo', { + response: createSystemInfoResponse(undefined), + }); + + await testUtils.render(); + + expect(screen.getByTestId('opcache-value').textContent).toBe('unknown'); + expect(screen.queryByTestId('opcache-warning')).toBeNull(); + + testUtils.cleanup(); + }); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index f2eeb7dd..48334632 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1857,6 +1857,11 @@ "memory": "Memory", "disk": "Disk", "php_memory": "PHP Memory Limit", + "opcache": "OPcache", + "opcache_enabled": "Enabled", + "opcache_disabled": "Disabled", + "opcache_unknown": "Unavailable", + "opcache_disabled_warning": "OPcache is disabled. PHP code is re-parsed on every request, which noticeably slows down site response times. We recommend setting opcache.enable=1 in php.ini. After changing the setting, this screen may take up to an hour to reflect it; clearing the cache applies it immediately.", "max_execution_time": "Max Execution Time", "upload_size": "Upload Size Limit", "install_paths": "Installation Paths", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index ced745e3..ce780fd8 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1861,6 +1861,11 @@ "memory": "메모리", "disk": "디스크", "php_memory": "PHP 메모리 제한", + "opcache": "OPcache", + "opcache_enabled": "활성화됨", + "opcache_disabled": "비활성화됨", + "opcache_unknown": "확인 불가", + "opcache_disabled_warning": "OPcache가 비활성화되어 있습니다. 매 요청마다 PHP 코드를 다시 해석하므로 사이트 응답 속도가 눈에 띄게 느려집니다. php.ini에서 opcache.enable=1 설정을 권장합니다. 설정을 변경한 뒤에도 이 화면에 반영되기까지 최대 1시간이 걸릴 수 있으며, 캐시 비우기를 실행하면 즉시 반영됩니다.", "max_execution_time": "최대 실행 시간", "upload_size": "업로드 크기 제한", "install_paths": "설치 경로", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json index 0719430d..2aa35922 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json @@ -254,7 +254,7 @@ "if": "{{_local.checkingCoreUpdate}}", "props": { "name": "spinner", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -333,7 +333,7 @@ "if": "{{_local.loadingChangelog}}", "props": { "name": "spinner", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -508,6 +508,32 @@ } ] }, + { + "id": "opcache_row", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label" + }, + "text": "$t:admin.settings.info.opcache" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "{{systemInfo?.data?.opcache?.enabled === true ? $t('admin.settings.info.opcache_enabled') : (systemInfo?.data?.opcache?.enabled === false ? $t('admin.settings.info.opcache_disabled') : $t('admin.settings.info.opcache_unknown'))}}" + } + ] + }, { "type": "basic", "name": "Div", @@ -561,6 +587,42 @@ ] } ] + }, + { + "id": "opcache_warning", + "if": "{{systemInfo?.data?.opcache?.enabled === false}}", + "type": "basic", + "name": "Div", + "props": { + "className": "alert-warning" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-warning-soft" + }, + "text": "$t:admin.settings.info.opcache_disabled_warning" + } + ] + } + ] } ] }, @@ -1157,7 +1219,7 @@ "name": "Icon", "props": { "name": "{{ext[1] ? 'check' : 'x'}}", - "className": "{{ext[1] ? 'w-4 h-4 text-green-500 flex-shrink-0' : 'w-4 h-4 text-red-500 flex-shrink-0'}}" + "className": "{{ext[1] ? 'text-base text-green-500 flex-shrink-0' : 'text-base text-red-500 flex-shrink-0'}}" } }, { @@ -1209,7 +1271,7 @@ "name": "Icon", "props": { "name": "{{ext[1] ? 'check' : 'x'}}", - "className": "{{ext[1] ? 'w-4 h-4 text-green-500 flex-shrink-0' : 'w-4 h-4 text-red-500 flex-shrink-0'}}" + "className": "{{ext[1] ? 'text-base text-green-500 flex-shrink-0' : 'text-base text-red-500 flex-shrink-0'}}" } }, { diff --git a/tests/Feature/Api/Admin/SettingsControllerTest.php b/tests/Feature/Api/Admin/SettingsControllerTest.php index 2655c944..a913856c 100644 --- a/tests/Feature/Api/Admin/SettingsControllerTest.php +++ b/tests/Feature/Api/Admin/SettingsControllerTest.php @@ -14,6 +14,7 @@ use App\Services\SettingsService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; /** @@ -1490,6 +1491,150 @@ class SettingsControllerTest extends TestCase ]); } + /** + * OPcache 상태가 시스템 정보 페이로드에 포함된다. + * + * 관리자 환경설정 > 정보 탭이 이 값으로 상태 행과 성능 저하 경고 블록을 렌더한다. + * enabled 는 true/false 외에 null("확인 불가" — ini_get 차단 환경)을 가질 수 있다. + * + * @scenario probe_item=opcache,probe_outcome=success,auth_state=authenticated_with_permission + * + * @effects opcache_status_exposed_in_system_info + */ + public function test_system_info_includes_opcache_status(): void + { + $cache = $this->app->make(CacheInterface::class); + $cache->forget('settings.system_info.'.app()->getLocale()); + + $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'opcache' => ['loaded', 'enabled'], + ], + ]); + + $opcache = $response->json('data.opcache'); + + $this->assertIsBool($opcache['loaded']); + $this->assertTrue( + $opcache['enabled'] === null || is_bool($opcache['enabled']), + 'opcache.enabled 는 true/false/null 중 하나여야 합니다.' + ); + } + + // 아래 test_system_info_reports_each_opcache_state 의 DataProvider 가 함께 검증하는 + // 나머지 3개 조합 (docblock 은 @scenario 를 1건만 인식하므로 라인 주석으로 병기). + // @scenario opcache_state=loaded_but_disabled, surface=system_info_api + // @effects opcache_status_exposed_in_system_info + // @scenario opcache_state=not_loaded, surface=system_info_api + // @effects opcache_status_exposed_in_system_info + // @scenario opcache_state=ini_get_blocked, surface=system_info_api + // @effects opcache_status_exposed_in_system_info + + /** + * OPcache 판정 결과가 API 페이로드에 그대로 실려 나온다. + * + * 관리자 정보 탭이 이 값으로 상태 행과 경고 블록을 렌더하므로, 네 가지 상태가 + * 각각 구별되어 전달되어야 한다. 특히 loaded_but_disabled(확장은 로드됐으나 + * opcache.enable=0)와 ini_get_blocked(null)이 뭉개지면 화면 판단이 무너진다. + * + * @scenario opcache_state=enabled, surface=system_info_api + * + * @effects opcache_status_exposed_in_system_info + */ + #[DataProvider('opcacheStateProvider')] + public function test_system_info_reports_each_opcache_state(array $probe): void + { + $cache = $this->app->make(CacheInterface::class); + $cache->forget('settings.system_info.'.app()->getLocale()); + + $service = new class($this->app, $probe) extends SettingsService + { + private array $stub; + + public function __construct($app, array $stub) + { + parent::__construct( + $app->make(ConfigRepositoryInterface::class), + $app->make(AttachmentRepositoryInterface::class), + $app->make(CacheInterface::class), + ); + $this->stub = $stub; + } + + protected function getOpcacheStatus(): array + { + return $this->stub; + } + }; + $this->app->instance(SettingsService::class, $service); + + $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); + + $response->assertStatus(200); + $this->assertSame($probe, $response->json('data.opcache')); + } + + /** + * @return array + */ + public static function opcacheStateProvider(): array + { + return [ + 'enabled' => [['loaded' => true, 'enabled' => true]], + 'loaded_but_disabled' => [['loaded' => true, 'enabled' => false]], + 'not_loaded' => [['loaded' => false, 'enabled' => false]], + 'ini_get_blocked' => [['loaded' => true, 'enabled' => null]], + ]; + } + + /** + * OPcache probe 가 실패해도 격리되어 전체 200 이 유지된다. + * + * ini_get 이 disable_functions 로 차단된 호스팅에서 probe 하나가 죽어 + * system-info API 전체가 500 이 나는 것을 방지한다. + * + * @scenario probe_item=opcache,probe_outcome=throws_error,auth_state=authenticated_with_permission + * + * @effects failing_probe_isolated_and_api_returns_200 + * @effects failed_item_filled_with_unknown_fallback + */ + public function test_system_info_isolates_failing_opcache_probe(): void + { + $cache = $this->app->make(CacheInterface::class); + $cache->forget('settings.system_info.'.app()->getLocale()); + + $service = new class($this->app) extends SettingsService + { + public function __construct($app) + { + parent::__construct( + $app->make(ConfigRepositoryInterface::class), + $app->make(AttachmentRepositoryInterface::class), + $app->make(CacheInterface::class), + ); + } + + protected function getOpcacheStatus(): array + { + throw new \Error('Call to undefined function ini_get() (disable_functions)'); + } + }; + $this->app->instance(SettingsService::class, $service); + + $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'opcache' => ['loaded' => false, 'enabled' => null], + ], + ]); + } + /** * 사용량 조회 실패 시 폴백 배열 구조 검증: unknownUsage 는 total/used/free 를 * 'unknown' 으로, percentage 를 0 으로 채워 memory_usage·disk_usage 형식을 유지한다. diff --git a/tests/Feature/Middleware/DatabaseCredentialGuardTest.php b/tests/Feature/Middleware/DatabaseCredentialGuardTest.php new file mode 100644 index 00000000..a4fd9265 --- /dev/null +++ b/tests/Feature/Middleware/DatabaseCredentialGuardTest.php @@ -0,0 +1,305 @@ + $username, + "database.connections.{$connection}.read.username" => $username, + "database.connections.{$connection}.write.username" => $username, + ]); + } + + /** + * 설치 완료 상태에서 정상 계정이면 웹 요청을 통과시켜야 한다. + * + * @scenario account_kind=normal, enforcement_point=runtime_web, install_state=completed + * + * @effects runtime_passes_through_with_normal_account + */ + public function test_passes_through_with_normal_account(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername('g7user'); + + $response = $this->get('/'); + + $this->assertNotEquals(503, $response->getStatusCode()); + } + + /** + * 정상 계정이면 API 요청도 통과시켜야 한다 (과잉 차단 방지). + * + * @scenario account_kind=normal, enforcement_point=runtime_api, install_state=completed + * + * @effects runtime_passes_through_with_normal_account + */ + public function test_passes_through_with_normal_account_for_api(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername('g7user'); + + $response = $this->getJson('/api/test'); + + $this->assertNotEquals(503, $response->getStatusCode()); + } + + /** + * 최고권한 계정이면 웹 요청에 503 전용 에러 페이지를 반환해야 한다. + * + * @scenario account_kind=privileged, enforcement_point=runtime_web, install_state=completed + * + * @effects runtime_returns_503_error_page + */ + #[DataProvider('privilegedUsernameProvider')] + public function test_returns_error_page_for_privileged_account(string $username): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername($username); + + $response = $this->get('/'); + + $response->assertStatus(503); + $response->assertViewIs('database-credential-error'); + } + + /** + * 빈 사용자명(설정 누락)도 동일하게 전용 에러 페이지를 반환해야 한다. + * + * @scenario account_kind=empty, enforcement_point=runtime_web, install_state=completed + * + * @effects runtime_returns_503_error_page + */ + #[DataProvider('emptyUsernameProvider')] + public function test_returns_error_page_for_empty_account(?string $username): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername($username); + + $response = $this->get('/'); + + $response->assertStatus(503); + $response->assertViewIs('database-credential-error'); + } + + /** + * @return array + */ + public static function privilegedUsernameProvider(): array + { + return [ + 'root' => ['root'], + 'postgres' => ['postgres'], + '대문자 ROOT (우회 차단)' => ['ROOT'], + '앞뒤 공백 " root " (우회 차단)' => [' root '], + ]; + } + + /** + * @return array + */ + public static function emptyUsernameProvider(): array + { + return [ + '빈 문자열 (설정 누락)' => [''], + 'null (설정 누락)' => [null], + ]; + } + + /** + * 최고권한 계정일 때 API 요청에는 HTML 이 아니라 JSON 503 을 반환해야 한다. + * + * @scenario account_kind=privileged, enforcement_point=runtime_api, install_state=completed + * + * @effects runtime_returns_503_json_for_api + */ + public function test_returns_json_for_api_requests(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername('root'); + + $response = $this->getJson('/api/test'); + + $response->assertStatus(503); + $response->assertJson(['success' => false]); + } + + /** + * 빈 사용자명일 때도 api/* 경로는 Accept 헤더와 무관하게 JSON 이어야 한다. + * + * @scenario account_kind=empty, enforcement_point=runtime_api, install_state=completed + * + * @effects runtime_returns_503_json_for_api + */ + public function test_returns_json_for_api_path_requests(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername(''); + + $response = $this->get('/api/settings'); + + $response->assertStatus(503); + $response->assertHeader('content-type', 'application/json'); + } + + /** + * 설치 전(installer_completed=false)에는 최고권한 계정이어도 통과해야 한다. + * + * 설치 전에는 DB 설정이 비어 있는 것이 정상이며, public/index.php 가 + * 이미 /install 로 유도한다. 여기서 막으면 설치 자체가 불가능해진다. + * + * @scenario account_kind=privileged, enforcement_point=runtime_web, install_state=not_completed + * + * @effects runtime_passes_through_when_not_installed + */ + public function test_passes_through_when_not_installed(): void + { + config(['app.installer_completed' => false]); + $this->setDbUsername('root'); + + $response = $this->get('/'); + + $this->assertNotEquals(503, $response->getStatusCode()); + } + + /** + * 설치 전에는 사용자명이 비어 있어도 통과해야 한다 (설치 전 정상 상태). + * + * @scenario account_kind=empty, enforcement_point=runtime_web, install_state=not_completed + * + * @effects runtime_passes_through_when_not_installed + */ + public function test_passes_through_when_not_installed_with_empty_username(): void + { + config(['app.installer_completed' => false]); + $this->setDbUsername(''); + + $response = $this->get('/'); + + $this->assertNotEquals(503, $response->getStatusCode()); + } + + /** + * 에러 페이지는 미인증 공개 페이지이므로 실제 DB 계정명을 노출하면 안 된다. + * + * @effects error_page_does_not_leak_account_name + */ + public function test_error_page_does_not_leak_account_name(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername('postgres'); + + $response = $this->get('/'); + $content = $response->getContent(); + + $response->assertStatus(503); + $this->assertStringNotContainsString('postgres', $content); + } + + /** + * 확장·템플릿이 로딩되지 않은 상태에서 뜨는 페이지이므로 + * 외부 에셋(JS/CSS)에 의존하지 않고 자체 완결이어야 한다. + * + * @effects error_page_is_self_contained_without_external_assets + */ + public function test_error_page_has_no_external_dependencies(): void + { + config(['app.installer_completed' => true]); + $this->setDbUsername('root'); + + $response = $this->get('/'); + $content = $response->getContent(); + + $response->assertStatus(503); + $this->assertStringNotContainsString('