feat(core,admin-template): 자산 URL 방식 감지 지점 보강 + 테스트 격리 결함 수정

계획서 §검증 후반부를 다시 대조해 미구현 3건을 확인하고 모두 구현했다. 앞선 커밋에서
"미구현 1건" 으로 보고한 것은 §12 불변식 표만 파일로 대조하고 §검증 섹션의 표 두 개는
확인하지 않은 결과였다.

- 설치 2단계 요구사항 화면에 자산 URL 방식 항목 추가. 판정은 서버가 아니라 브라우저가
 한다 — 서버에서 자기 APP_URL 로 요청하면 loopback 이 vhost·프록시 체인을 우회해
 실제 방문자와 다른 답을 낸다. 2단계와 3단계가 같은 판정 함수를 쓰도록 추출했다
- 대시보드가 스스로 프로브를 던져 **저장된** 방식과 대조하고 어긋나면 안내한다.
 런타임 값이 아니라 저장값을 보는 이유는, 자가 복구가 이미 전환해 놓았을 때가 바로
 알려야 할 상황이기 때문이다 — 봇은 자바스크립트를 실행하지 않아 자가 복구가 닿지 않는다.
 알리기만 하고 저장하지 않으므로 클라이언트가 서버 설정을 뒤집지 않는다
- 레이아웃 렌더링 테스트를 두 모드로 추가. 핵심 단언은 두 모드의 DOM 이 완전히 동일하다는
 것이다 — 모드가 화면 구조로 새어나가면 확장자 없는 환경에서만 깨지고 그 환경은 개발 중
 거의 재현되지 않는다

함께 고친 테스트 격리 결함 4건 (이번 기능과 무관하나 같은 세션에서 발견):

- 인스톨러 공유 클래스 로드가 오토로드를 막고 있어, 앱 루트를 임시 디렉토리로 바꾸는
 테스트에서 없는 파일을 읽으려다 죽었다 (8건 fatal)
- 테스트용 번역 스텁이 실제 구현과의 정의 경쟁에서 이기면 주입된 번역을 무시했다.
 클래스 단독 실행은 통과하고 스위트 실행만 실패하던 원인
- 모듈 활성 목록은 "디스크 로드분 ∩ DB 활성분" 인데 부팅 시 테이블이 비어 디스크 스캔이
 일어나지 않아, 파일 캐시 잔재가 있을 때만 통과하던 순서 의존 테스트
- 라우트 프로바이더 가드 테스트가 Git 미추적 파일(.env) 존재에 의존해 신규 클론·CI 에서
 실패했다. 임시 앱 루트로 격리

또한 일부 테스트가 실제 개발 환경 파일을 삭제·재생성하고 자신의 정리 단계에서 되돌리는데,
그 테스트가 죽으면 되돌아오지 않아 복구 불가능한 유실이 발생한다. 실제로 이번 세션에서
겪었으므로 테스트 부트스트랩에 프로세스 수준 복원 안전망을 추가했다.

검증: 백엔드 344건 / 템플릿 2799건 / 레이아웃 7건 / 브라우저 9건 green, audit 위반 0.
This commit is contained in:
HeuJung
2026-07-23 11:39:57 +09:00
parent 15cd4452bf
commit 072b3f50d9
23 changed files with 918 additions and 74 deletions
+61 -30
View File
@@ -1,5 +1,8 @@
<?php
use App\Support\OpcacheStatus;
use App\Support\PrivilegedDatabaseAccounts;
/**
* G7 인스톨러 - 검증 통합 API
*
@@ -70,6 +73,7 @@ class ValidationApi
'required_files' => $this->checkRequiredFiles(),
'https' => $this->checkHttps(),
'opcache' => $this->checkOpcache(),
'asset_url_mode' => $this->checkAssetUrlMode(),
];
// 모든 필수 요구사항 통과 여부
@@ -141,7 +145,7 @@ class ValidationApi
// DB 최고권한 계정 차단 (Write/Read 공통).
// 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로
// 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT.
if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) {
if (PrivilegedDatabaseAccounts::isBlocked($username)) {
throw new Exception(
lang('error_db_username_privileged', ['username' => $username])
);
@@ -190,7 +194,7 @@ class ValidationApi
// 기존 테이블 감지 (Write DB만 수행)
// 사용자가 입력한 db_prefix를 g7 시그니처에 적용하여 정확히 비교
$existingTables = null;
if (!$isReadDb) {
if (! $isReadDb) {
$detectPrefix = ($input['db_prefix'] ?? '') !== '' ? (string) $input['db_prefix'] : 'g7_';
$existingTables = checkExistingTables($pdo, $database, $detectPrefix);
}
@@ -526,7 +530,7 @@ class ValidationApi
*/
private function checkOpcache(): array
{
$status = \App\Support\OpcacheStatus::probe();
$status = OpcacheStatus::probe();
if ($status['enabled'] === null) {
$message = lang('opcache_unknown');
@@ -544,6 +548,27 @@ class ValidationApi
];
}
/**
* 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음)
*
* 판정 자체는 **서버가 할 수 없다.** 서버에서 자기 APP_URL 로 curl 하면 loopback 이
* nginx vhost·SSL·프록시 체인을 우회하거나 다른 vhost 를 타서, 실제 방문자가 겪는
* 것과 다른 답을 낸다. 그래서 여기서는 프로브 경로만 내려주고 실제 판정은
* 브라우저가 수행한다 (`installer.js::probeAssetUrlMode`).
*
* `detected` 가 항상 null 인 것은 미구현이 아니라 위 이유에 따른 설계다.
* 프론트가 이 값을 받아 카드를 "확인 중" 상태로 먼저 그리고, 프로브가 끝나면 갱신한다.
*/
private function checkAssetUrlMode(): array
{
return [
'required' => false, // 어느 방식이든 정상 — 통과/실패 게이트가 아니다
'detected' => null, // 브라우저 프로브가 채운다 (서버 판정 불가)
'probe_extension' => '/api/system/asset-probe.js',
'probe_extensionless' => '/api/system/asset-probe',
];
}
/**
* 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증
*/
@@ -601,7 +626,7 @@ class ValidationApi
$returnCode = -1;
exec('php --version 2>&1', $output, $returnCode);
if ($returnCode === 0 && !empty($output)) {
if ($returnCode === 0 && ! empty($output)) {
$outputStr = implode("\n", $output);
if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$cliVersion = $matches[1];
@@ -649,7 +674,7 @@ class ValidationApi
$minVersion = MIN_PHP_VERSION;
// PHP_BINARY 상수
if (defined('PHP_BINARY') && PHP_BINARY !== '' && !in_array(PHP_BINARY, $checkedPaths, true)) {
if (defined('PHP_BINARY') && PHP_BINARY !== '' && ! in_array(PHP_BINARY, $checkedPaths, true)) {
$checkedPaths[] = PHP_BINARY;
$result = $this->validatePhpPath(PHP_BINARY);
if ($result['valid']) {
@@ -663,7 +688,7 @@ class ValidationApi
continue;
}
$checkedPaths[] = $path;
if (!file_exists($path)) {
if (! file_exists($path)) {
continue;
}
$result = $this->validatePhpPath($path);
@@ -674,7 +699,7 @@ class ValidationApi
// 시스템 PATH의 'php'
$defaultPhpAvailable = false;
if (!in_array('php', $checkedPaths, true)) {
if (! in_array('php', $checkedPaths, true)) {
$result = $this->validatePhpPath('php');
if ($result['valid']) {
$found[] = ['path' => 'php', 'version' => $result['version']];
@@ -691,11 +716,11 @@ class ValidationApi
}
// Composer 자동 감지 (감지된 PHP 중 첫 번째로 검증)
$phpForComposer = !empty($found) ? $found[0]['path'] : 'php';
$phpForComposer = ! empty($found) ? $found[0]['path'] : 'php';
$composerResult = $this->detectComposerBinary($phpForComposer);
echo json_encode([
'success' => !empty($found),
'success' => ! empty($found),
'binaries' => $found,
'default_php_available' => $defaultPhpAvailable,
'composer' => $composerResult,
@@ -706,7 +731,7 @@ class ValidationApi
/**
* Composer 바이너리 자동 감지
*
* @param string $phpPath .phar 실행 시 사용할 PHP 경로
* @param string $phpPath .phar 실행 시 사용할 PHP 경로
* @return array{found: bool, path: string|null, version: string|null}
*/
private function detectComposerBinary(string $phpPath = 'php'): array
@@ -718,7 +743,7 @@ class ValidationApi
}
// 2. 프로젝트 루트의 composer.phar
$pharPath = realpath(__DIR__ . '/../../..') . '/composer.phar';
$pharPath = realpath(__DIR__.'/../../..').'/composer.phar';
if (file_exists($pharPath)) {
$result = $this->validateComposerPath($pharPath, $phpPath);
if ($result['valid']) {
@@ -727,7 +752,7 @@ class ValidationApi
}
// 3. 현재 디렉토리(public/install/api)의 composer.phar
$localPhar = realpath(__DIR__) . '/composer.phar';
$localPhar = realpath(__DIR__).'/composer.phar';
if (file_exists($localPhar)) {
$result = $this->validateComposerPath($localPhar, $phpPath);
if ($result['valid']) {
@@ -792,6 +817,7 @@ class ValidationApi
$path = $_GET['path'] ?? '';
if (empty($path)) {
echo json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE);
return;
}
@@ -804,22 +830,24 @@ class ValidationApi
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
$candidatePath = (str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[/\\\\]#', $path) === 1)
? $path
: BASE_PATH . DIRECTORY_SEPARATOR . $path;
: BASE_PATH.DIRECTORY_SEPARATOR.$path;
$resolved = @realpath($candidatePath);
if ($resolved === false || !is_dir($resolved)) {
if ($resolved === false || ! is_dir($resolved)) {
// 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여
// 임의 경로 enumeration 신호 차단.
echo json_encode([
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
@@ -850,7 +878,7 @@ class ValidationApi
return false;
}
return !preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
return ! preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
}
/**
@@ -860,16 +888,16 @@ class ValidationApi
* `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다.
* 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
private function splitPhpComposerTokens(string $path): ?array
{
if (!str_contains($path, ' ')) {
if (! str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (!is_array($tokens) || count($tokens) !== 2) {
if (! is_array($tokens) || count($tokens) !== 2) {
return null;
}
@@ -884,7 +912,7 @@ class ValidationApi
/**
* PHP 바이너리 경로 유효성 검증 헬퍼
*
* @param string $path PHP 바이너리 경로
* @param string $path PHP 바이너리 경로
* @return array{valid: bool, version: string|null, message: string}
*/
private function validatePhpPath(string $path): array
@@ -896,11 +924,11 @@ class ValidationApi
// 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
// 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && !$this->isInstallerSafePathArg($path)) {
if ($path !== 'php' && ! $this->isInstallerSafePathArg($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
}
$command = escapeshellarg($path) . ' --version 2>&1';
$command = escapeshellarg($path).' --version 2>&1';
$output = [];
$returnCode = -1;
exec($command, $output, $returnCode);
@@ -919,6 +947,7 @@ class ValidationApi
'message' => lang('success_php_binary_version', ['path' => $path, 'version' => $version]),
];
}
return [
'valid' => false,
'version' => $version,
@@ -932,8 +961,8 @@ class ValidationApi
/**
* Composer 바이너리 경로 유효성 검증 헬퍼
*
* @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer')
* @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용)
* @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer')
* @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용)
* @return array{valid: bool, version: string|null, message: string}
*/
private function validateComposerPath(string $composerPath, string $phpPath = 'php'): array
@@ -947,8 +976,8 @@ class ValidationApi
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
$tokens = $this->splitPhpComposerTokens($effectivePath);
if ($tokens === null
|| !$this->isInstallerSafePathArg($tokens['php'])
|| !$this->isInstallerSafePathArg($tokens['composer'])
|| ! $this->isInstallerSafePathArg($tokens['php'])
|| ! $this->isInstallerSafePathArg($tokens['composer'])
) {
return [
'valid' => false,
@@ -957,7 +986,7 @@ class ValidationApi
];
}
$command = escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']) . ' --version 2>&1';
$command = escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']).' --version 2>&1';
$output = [];
$returnCode = -1;
@@ -971,6 +1000,7 @@ class ValidationApi
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
@@ -983,7 +1013,7 @@ class ValidationApi
// 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
if ($effectivePath !== 'composer' && !$this->isInstallerSafePathArg($effectivePath)) {
if ($effectivePath !== 'composer' && ! $this->isInstallerSafePathArg($effectivePath)) {
return [
'valid' => false,
'version' => null,
@@ -994,16 +1024,16 @@ class ValidationApi
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with($effectivePath, '.phar')) {
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
if ($phpPath !== 'php' && !$this->isInstallerSafePathArg($phpPath)) {
if ($phpPath !== 'php' && ! $this->isInstallerSafePathArg($phpPath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_php_exec_failed', ['path' => $phpPath]),
];
}
$command = escapeshellarg($phpPath) . ' ' . escapeshellarg($effectivePath) . ' --version 2>&1';
$command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1';
} else {
$command = escapeshellarg($effectivePath) . ' --version 2>&1';
$command = escapeshellarg($effectivePath).' --version 2>&1';
}
$output = [];
@@ -1020,6 +1050,7 @@ class ValidationApi
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
+78 -12
View File
@@ -142,6 +142,10 @@
document.getElementById('requirements-result').innerHTML = resultHtml;
document.getElementById('navigation-buttons').classList.remove('hidden');
// 자산 URL 방식은 브라우저만 판정할 수 있어 카드 삽입 후 비동기로 채운다.
// await 하지 않는다 — 프로브가 늦거나 실패해도 요구사항 화면을 막지 않는다.
refreshAssetUrlModeCard();
// 버튼 표시 제어
const recheckBtn = document.getElementById('recheck-btn');
const nextBtn = document.getElementById('next-btn');
@@ -468,6 +472,20 @@
);
}
// 10. 자산 URL 방식 카드 (안내 항목, 필수 아님)
// 서버는 판정할 수 없으므로(loopback 이 vhost·프록시를 우회) "확인 중" 으로 먼저
// 그리고 refreshAssetUrlModeCard() 가 브라우저 프로브 결과로 갱신한다.
// failedRequirements.push() 를 하지 않는다 — 어느 방식이든 정상 동작이다.
if (data.asset_url_mode) {
html += `<div id="asset-url-mode-card">${renderSingleItemCard(
lang('asset_url_mode'),
'status-warning',
lang('asset_url_mode_checking'),
'',
false
)}</div>`;
}
html += '</div>';
// 필수 조건 미충족 시 에러 박스 표시
@@ -1555,7 +1573,11 @@
* 실시간 검증 초기화
*/
/**
* 자산 URL 방식을 브라우저에서 판정해 hidden 필드에 채운다 (이슈 #486 §6·§7).
* 자산 URL 방식을 브라우저에서 판정한다 (이슈 #486 §6·§7).
*
* Step 2 요구사항 카드와 Step 3 설정 폼이 **같은 함수를 쓴다.** 판정 로직을 두 벌
* 두면 한쪽만 고쳐져 화면마다 다른 답을 내놓는다 (이 이슈에서 이미 URL 생성부가
* 그렇게 어긋났다).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라 프리픽스
* location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 응답이
@@ -1572,19 +1594,16 @@
* "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서
* 영원히 오판한다.
*/
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
async function probeAssetUrlMode(base) {
const TOKEN = 'G7_ASSET_PROBE_OK';
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
const root = (base || '').replace(/\/+$/, '');
/**
* 프로브 1건을 던져 매직 토큰 포함 여부를 반환한다.
*/
const probe = async (path) => {
try {
const res = await fetch(`${base}${path}`, { cache: 'no-store', credentials: 'omit' });
const res = await fetch(`${root}${path}`, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) return false;
// Content-Type 도 함께 본다 (L6). 토큰만 검사해도 200+HTML 오판은
@@ -1603,13 +1622,60 @@
probe('/api/system/asset-probe'),
]);
let detected = '';
if (withExt && withoutExt) {
detected = 'extension';
} else if (!withExt && withoutExt) {
detected = 'extensionless';
return 'extension';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 값으로 두어 기본값을 따르게 한다.
if (!withExt && withoutExt) {
return 'extensionless';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 문자열로 판정 보류.
return '';
}
/**
* Laravel 앱 루트 경로를 반환한다 (`INSTALLER_BASE_URL` 은 항상 `/install` 로 끝난다).
*
* Step 2 에는 `app_url` 입력이 아직 없으므로 현재 origin 기준으로 조립한다.
*/
function getAppRootUrl() {
const base = (window.INSTALLER_BASE_URL || '').replace(/\/install$/, '');
return `${window.location.origin}${base}`;
}
/**
* Step 2 요구사항 카드의 자산 URL 방식 항목을 프로브 결과로 갱신한다 (§7).
*
* 서버는 이 값을 판정할 수 없어(loopback 이 vhost·프록시를 우회) 카드가 먼저
* "확인 중" 으로 그려진 뒤 여기서 채워진다. 통과/실패 게이트가 아니라 안내 항목이라
* `failedRequirements` 에 넣지 않는다 — 어느 방식이든 정상 동작이다.
*/
async function refreshAssetUrlModeCard() {
const slot = document.getElementById('asset-url-mode-card');
if (!slot) return;
const detected = await probeAssetUrlMode(getAppRootUrl());
// 확장자 미사용은 "서버가 확장자 주소를 가로채고 있다" 는 신호이므로 눈에 띄게 둔다.
// 판정 불가도 마찬가지 — 설치 후 백지 화면의 예고일 수 있다.
const statusClass = detected === 'extension' ? 'status-pass' : 'status-warning';
const label = detected === 'extension'
? lang('asset_url_mode_extension')
: (detected === 'extensionless' ? lang('asset_url_mode_extensionless') : lang('asset_url_mode_unknown'));
// 카드 내부를 부분 수정하지 않고 통째로 다시 그린다 — 아이콘·수식자 클래스가
// renderSingleItemCard 안에서 statusClass 로 함께 결정되므로, 밖에서 일부만
// 건드리면 아이콘과 카드 색이 어긋난다.
slot.innerHTML = renderSingleItemCard(lang('asset_url_mode'), statusClass, label, '', false);
}
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
const detected = await probeAssetUrlMode(base);
field.value = detected;
+7 -2
View File
@@ -218,9 +218,14 @@ if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) {
// 두 클래스는 프레임워크 의존성이 0 이라 이 방식이 성립하며, 인스톨러와 코어가
// 서로 다른 판정을 내리는 것을 구조적으로 차단한다.
// class_exists 가드: Laravel 부트가 선행된 테스트 환경에서 중복 로드 방지.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts', false)) {
//
// 오토로드를 **막지 않는다**(2번째 인자 생략). `false` 를 주면 오토로드 가능한
// 클래스를 못 본 채 require 로 내려가는데, BASE_PATH 를 임시 디렉토리로 바꿔 두는
// 인스톨러 단위 테스트에서는 그 경로에 app/Support 가 없어 fatal 이 된다.
// 순수 인스톨러 실행 시에는 등록된 오토로더가 없어 false 를 반환하므로 require 가 그대로 돈다.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts')) {
require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php';
}
if (!class_exists('App\\Support\\OpcacheStatus', false)) {
if (!class_exists('App\\Support\\OpcacheStatus')) {
require_once BASE_PATH . '/app/Support/OpcacheStatus.php';
}
+2
View File
@@ -841,6 +841,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'asset_url_mode_detected_extension' => 'Detected: file extensions work fine on this server.',
'asset_url_mode_detected_extensionless' => 'Detected: the server intercepts extension URLs, so no extensions was selected.',
'asset_url_mode_detected_unavailable' => 'Could not detect. If pages fail to load after installation, try switching to no extensions.',
'asset_url_mode_checking' => 'Checking…',
'asset_url_mode_unknown' => 'Unknown',
'core_update_github_url' => 'GitHub Repository URL',
'core_update_github_url_help' => 'GitHub repository URL to check for core updates.',
'core_update_github_token' => 'GitHub Access Token',
+2
View File
@@ -841,6 +841,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'asset_url_mode_detected_extension' => '감지 결과: 확장자를 사용할 수 있는 환경입니다.',
'asset_url_mode_detected_extensionless' => '감지 결과: 서버가 확장자 주소를 가로채고 있어 확장자 미사용을 선택했습니다.',
'asset_url_mode_detected_unavailable' => '감지하지 못했습니다. 설치 후 화면이 뜨지 않으면 확장자 미사용으로 바꿔 보세요.',
'asset_url_mode_checking' => '확인 중…',
'asset_url_mode_unknown' => '확인 불가',
'core_update_github_url' => 'GitHub 저장소 URL',
'core_update_github_url_help' => '코어 업데이트를 확인할 GitHub 저장소 URL입니다.',
'core_update_github_token' => 'GitHub 액세스 토큰',