feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드

nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
This commit is contained in:
HeuJung
2026-07-23 11:34:32 +09:00
parent 74df8a4b09
commit 15cd4452bf
90 changed files with 4279 additions and 546 deletions
+12
View File
@@ -301,6 +301,18 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
### 정적 확장자 라우트 / 자산 URL 생성
| 금지 | 올바른 사용 |
|------|------------|
| `Route::get('{id}/routes.json', ...)` (`.js`/`.css`/`.json`/`.map` 단일 등록) | `Route::dualSuffix('{id}/routes', 'json', ...)` — 확장자 형태 + 확장자 없는 형태 동시 등록 |
| `Route::get('bundle.js', ...)` (접미사가 종류를 구분해 제거 불가) | `Route::dualSuffixSegment('bundle', 'js', ...)` (`bundle.js` + `bundle/js`) |
| `Route::get('assets/{id}/{path}', ...)` (와일드카드 자산) | `Route::dualAsset('assets/{id}', ...)` (`.../{path}` + `?file=` 쿼리) |
| 서버에서 `'/api/templates/assets/'.$id.'/'.$path` 문자열 조립 | `App\Support\AssetUrl::templateAsset($id, $path)` |
| 프론트에서 `` `/api/templates/${id}/routes.json` `` 템플릿 리터럴 조립 | `resources/js/core/support/assetUrl.ts` 의 `suffixed()` / `templateAsset()` 등 |
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
### Listener 데이터 접근
| 금지 | 올바른 사용 |
+2
View File
@@ -18,6 +18,8 @@
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
### Changed
+43
View File
@@ -77,6 +77,49 @@ server {
}
```
#### 정적 파일 최적화 블록을 함께 쓰는 경우
아래처럼 확장자로 캐시 규칙을 거는 블록(aaPanel · CyberPanel · Plesk 기본 템플릿에
포함되어 있습니다)을 함께 사용한다면 주의가 필요합니다.
```nginx
location ~* \.(js|css|json|png|jpg|svg|woff2?)$ {
expires max;
access_log off;
}
```
nginx 는 **정규식 location 을 프리픽스 location(`location /`)보다 먼저** 매칭합니다.
G7 은 일부 동적 엔드포인트에 `.js` · `.css` · `.json` 확장자를 쓰므로, 위 블록 안에
PHP 핸들러가 없으면 그 요청들이 `try_files ... /index.php` 폴백에 닿지 못하고
nginx 가 직접 파일을 찾다가 404 를 반환합니다. 증상은 **모든 페이지가 백지**입니다.
해결 방법은 두 가지이며, 아무것도 하지 않아도 G7 이 스스로 복구합니다.
1. **그대로 두기** — 브라우저가 자산 로드 실패를 감지하면 확장자 없는 주소로 자동
전환합니다. 설치 마법사도 설치 시점에 이를 감지해 설정에 반영합니다.
설치 후 확정하려면 `php artisan g7:asset-url-mode extensionless` 를 실행하거나
관리자 > 환경설정 > 일반에서 "자산 파일 주소 방식" 을 변경하세요.
(검색엔진 봇은 JavaScript 를 실행하지 않으므로, SEO 를 쓴다면 이 확정을 권장합니다.)
2. **`/api/` 를 정규식 블록보다 우선시키기** — `^~` 는 정규식 location 보다 우선하므로
아래 블록을 추가하면 `/api/` 요청이 항상 PHP 로 갑니다. 확장자 기반 캐시 최적화를
정적 파일에만 그대로 유지할 수 있습니다.
```nginx
location ^~ /api/ {
try_files $uri $uri/ /index.php?$query_string;
}
```
서버가 동적 응답을 가로채는지는 아래 두 요청을 비교해 확인할 수 있습니다.
첫 번째만 실패하면 가로채는 것입니다.
```bash
curl -i https://example.com/api/system/asset-probe.js
curl -i https://example.com/api/system/asset-probe
```
### 3단계: 설치 마법사 실행
브라우저에서 접속합니다.
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Services\SettingsService;
use App\Support\AssetUrl;
use Illuminate\Console\Command;
/**
* 자산 URL 모드 조회/전환 Artisan 커맨드 (이슈 #486 §9)
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 동적 응답을 가로채는 서버에서는
* **관리자 화면 자체가 뜨지 않는다**. "브라우저로 설정을 바꾸세요" 는 순환 참조이므로
* CLI 탈출구가 반드시 필요하다.
*
* ```
* php artisan g7:asset-url-mode # 현재 모드 + 진단 안내
* php artisan g7:asset-url-mode extensionless # 전환
* ```
*/
class AssetUrlModeCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'g7:asset-url-mode
{mode? : 전환할 모드 (extension | extensionless). 생략 시 현재 모드만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '자산 URL 모드를 조회하거나 전환합니다 (정적 최적화 서버 대응)';
/**
* 커맨드를 실행합니다.
*
* @param SettingsService $settingsService 환경설정 서비스
* @param SeoCacheManagerInterface $seoCacheManager SEO 캐시 매니저
* @return int 종료 코드
*/
public function handle(SettingsService $settingsService, SeoCacheManagerInterface $seoCacheManager): int
{
$current = AssetUrl::mode();
$requested = $this->argument('mode');
if ($requested === null) {
$this->showStatus($current);
return Command::SUCCESS;
}
if (! in_array($requested, [AssetUrl::MODE_EXTENSION, AssetUrl::MODE_EXTENSIONLESS], true)) {
$this->error(__('asset_url_mode.invalid', ['mode' => $requested]));
return Command::FAILURE;
}
if ($requested === $current) {
$this->info(__('asset_url_mode.already', ['mode' => $current]));
return Command::SUCCESS;
}
$saved = $settingsService->saveSettings([
'_tab' => 'general',
'general' => ['asset_url_mode' => $requested],
]);
if (! $saved) {
$this->error(__('asset_url_mode.save_failed'));
return Command::FAILURE;
}
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드를 바꾸면 그 URL 들이 전부 어긋나므로 함께 비운다 (계획서 §알려진 한계).
$seoCacheManager->clearAll();
$this->info(__('asset_url_mode.switched', ['from' => $current, 'to' => $requested]));
$this->line(__('asset_url_mode.seo_cache_cleared'));
return Command::SUCCESS;
}
/**
* 현재 모드와 진단 안내를 출력합니다.
*
* @param string $current 현재 모드
*/
private function showStatus(string $current): void
{
$this->line('');
$this->line(' '.__('asset_url_mode.current', ['mode' => $current]));
$this->line('');
// audit:allow asset-url-builder-required reason: 두 모드의 URL 형태를 나란히
// 보여주는 대조표라 빌더로 생성할 수 없다 (빌더는 현재 모드 하나만 만든다).
$rows = $current === AssetUrl::MODE_EXTENSION
? [
['/api/templates/{id}/routes.json', '/api/templates/{id}/routes'],
['/api/modules/bundle.js', '/api/modules/bundle/js'],
['/api/templates/assets/{id}/js/a.js', '/api/templates/assets/{id}?file=js/a.js'],
]
: [
['/api/templates/{id}/routes', '/api/templates/{id}/routes.json'],
['/api/modules/bundle/js', '/api/modules/bundle.js'],
['/api/templates/assets/{id}?file=js/a.js', '/api/templates/assets/{id}/js/a.js'],
];
$this->table(
[__('asset_url_mode.table.in_use'), __('asset_url_mode.table.alternative')],
$rows,
);
$this->line(' '.__('asset_url_mode.diagnose_title'));
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe.js');
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe');
$this->line('');
$this->line(' '.__('asset_url_mode.diagnose_hint'));
$this->line('');
$this->line(' '.__('asset_url_mode.switch_hint'));
$this->line('');
}
}
+7 -6
View File
@@ -41,6 +41,7 @@ use App\Models\Plugin;
use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -1066,8 +1067,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1156,8 +1157,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1304,8 +1305,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
+7 -6
View File
@@ -41,6 +41,7 @@ use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -1094,8 +1095,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1190,8 +1191,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1268,8 +1269,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -8,6 +8,7 @@ use App\Extension\Helpers\EditorSpecAssembler;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PermissionService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -88,17 +89,19 @@ class AdminTemplateAssetController extends AdminBaseController
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? "?v={$extensionCacheVersion}" : '';
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
return $this->success(
__('templates.messages.editor_assets_retrieved'),
[
'identifier' => $identifier,
'js' => ["/api/templates/assets/{$identifier}/js/components.iife.js{$version}"],
'js' => [AssetUrl::templateAsset($identifier, 'js/components.iife.js', $version)],
// CSS 는 편집기 전용 엔드포인트로 — 다크 셀렉터를 프리뷰 마커로 치환해 서빙
// 일반 자산 서빙은 원본.
// URI 가 `components` 가 아니라 `component-styles` 인 이유: 확장자를 떼면
// `editor/components.json` 의 확장자 없는 형태와 충돌한다.
'css' => $cssAvailable
? ["/api/admin/templates/{$identifier}/editor/components.css{$version}"]
? [AssetUrl::suffixed("/api/admin/templates/{$identifier}/editor/component-styles", 'css', $version)]
: [],
'manifest_present' => true,
'manifest_source' => $jsSource,
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\Response;
/**
* 자산 URL 모드 감지 프로브.
*
* 서버(nginx/Apache)의 정적 최적화 블록이 확장자 붙은 동적 응답을 가로채는지
* 판정하기 위한 대조 엔드포인트다. 브라우저가 아래 두 URL 을 쌍으로 요청한다.
*
* ```text
* GET /api/system/asset-probe.js → 확장자 형태 (정적 블록의 표적)
* GET /api/system/asset-probe → 대조군
* ```
*
* | probe.js | probe | 판정 |
* |---|---|---|
* | 성공 | 성공 | `extension` — 확장자 유지 가능 |
* | 실패 | 성공 | `extensionless` — 정적 블록 가로채기 확정 |
* | 실패 | 실패 | 모드 문제 아님 (PHP/라우팅 장애) — 별도 안내 |
*
* ## 판정은 상태코드가 아니라 본문이다
*
* 클라이언트는 `res.ok && body.includes(PROBE_TOKEN)` 로 판정해야 한다.
* 상태코드만 보면 "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를
* 반환하는 설정에서 영원히 `extension` 으로 오판해, 재감지를 몇 번 눌러도
* 같은 오답이 나온다.
*
* ## 감지는 반드시 브라우저에서 수행한다
*
* 서버측에서 자기 `APP_URL` 로 curl 하면 loopback 이 nginx vhost·SSL·프록시
* 체인을 우회하거나 다른 vhost 를 타서 오판한다.
*
* ## 실물 파일을 두지 않는다
*
* `public/` 하위에 실제 `asset-probe.js` 파일이 존재하면 nginx 가 그것을
* 성공적으로 서빙해 거짓 양성이 된다. 본 라우트는 반드시 실물 파일이 없는
* 경로여야 한다.
*/
class AssetProbeController extends PublicBaseController
{
/**
* 프로브 성공 판정용 매직 토큰.
*
* 클라이언트·테스트가 응답 본문에서 이 문자열을 찾아 성공을 판정한다.
*/
public const PROBE_TOKEN = 'G7_ASSET_PROBE_OK';
/**
* 프로브 응답을 반환합니다.
*
* DB 에 접근하지 않으며(설치 전에도 응답 가능) 캐시되지 않습니다.
* ResponseHelper 의 JSON 봉투를 쓰지 않는 이유: 이 엔드포인트의 목적은
* "정적 자산으로 오인될 응답"을 실제로 흉내내는 것이므로, 자바스크립트
* Content-Type 과 본문 형태를 그대로 유지해야 대표성이 있다.
*
* @return Response 매직 토큰을 담은 자바스크립트 응답
*/
public function probe(): Response
{
$body = "/* G7 asset URL mode probe */\n"
."window.__g7AssetProbe = '".self::PROBE_TOKEN."';\n";
return response($body, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'X-Content-Type-Options' => 'nosniff',
]);
}
}
@@ -71,14 +71,16 @@ class PublicModuleController extends PublicBaseController
*
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @param string $path 에셋 경로 (dist/js/module.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServeModuleAssetRequest $request,
string $identifier,
string $path
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('modules.assets', ['identifier' => $identifier, 'path' => $path]);
@@ -70,14 +70,16 @@ class PublicPluginController extends PublicBaseController
*
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @param string $path 에셋 경로 (dist/js/plugin.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServePluginAssetRequest $request,
string $identifier,
string $path
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('plugins.assets', ['identifier' => $identifier, 'path' => $path]);
@@ -88,13 +88,16 @@ class PublicTemplateController extends PublicBaseController
/**
* 템플릿 정적 파일 서빙
*
* @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증)
* @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증, 파일 경로 `path` 포함)
* @param string $identifier 템플릿 식별자
* @param string $path 요청 경로
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러
*/
public function serveAsset(ServeTemplateAssetRequest $request, string $identifier, string $path): BinaryFileResponse|JsonResponse|Response
public function serveAsset(ServeTemplateAssetRequest $request, string $identifier): BinaryFileResponse|JsonResponse|Response
{
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('templates.assets', ['identifier' => $identifier, 'path' => $path]);
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Module;
use App\Rules\AllowedModuleFileType;
use App\Rules\SafeModulePath;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Foundation\Http\FormRequest;
class ServeModuleAssetRequest extends FormRequest
@@ -12,6 +13,8 @@ class ServeModuleAssetRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -21,8 +24,11 @@ class ServeModuleAssetRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed>
* @return array<string, mixed> 검증 규칙 배열
*/
// audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템
// 화이트리스트(SafeModulePath + AllowedModuleFileType)가 유일한 방어선이다.
// 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다.
public function rules(): array
{
// 모듈 식별자로부터 기준 경로 구성 (모듈 루트)
@@ -35,7 +41,7 @@ class ServeModuleAssetRequest extends FormRequest
'required',
'string',
new SafeModulePath($basePath),
new AllowedModuleFileType(),
new AllowedModuleFileType,
],
];
}
@@ -45,10 +51,11 @@ class ServeModuleAssetRequest extends FormRequest
*/
protected function prepareForValidation(): void
{
// 라우트 파라미터를 검증 데이터에 병합
// 라우트 파라미터를 검증 데이터에 병합.
// 확장자 없는 모드에서는 파일 경로가 `?file=` 쿼리로 온다 (경로 확장자 회피).
$this->merge([
'identifier' => $this->route('identifier'),
'path' => $this->route('path'),
'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM),
]);
}
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Plugin;
use App\Rules\AllowedPluginFileType;
use App\Rules\SafePluginPath;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Foundation\Http\FormRequest;
class ServePluginAssetRequest extends FormRequest
@@ -12,6 +13,8 @@ class ServePluginAssetRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -21,8 +24,11 @@ class ServePluginAssetRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed>
* @return array<string, mixed> 검증 규칙 배열
*/
// audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템
// 화이트리스트(SafePluginPath + AllowedPluginFileType)가 유일한 방어선이다.
// 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다.
public function rules(): array
{
// 플러그인 식별자로부터 기준 경로 구성 (플러그인 루트)
@@ -35,7 +41,7 @@ class ServePluginAssetRequest extends FormRequest
'required',
'string',
new SafePluginPath($basePath),
new AllowedPluginFileType(),
new AllowedPluginFileType,
],
];
}
@@ -48,7 +54,7 @@ class ServePluginAssetRequest extends FormRequest
// 라우트 파라미터를 검증 데이터에 병합
$this->merge([
'identifier' => $this->route('identifier'),
'path' => $this->route('path'),
'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM),
]);
}
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Template;
use App\Rules\AllowedTemplateFileType;
use App\Rules\SafeTemplatePath;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Foundation\Http\FormRequest;
class ServeTemplateAssetRequest extends FormRequest
@@ -12,6 +13,8 @@ class ServeTemplateAssetRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -20,7 +23,13 @@ class ServeTemplateAssetRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed> 검증 규칙 배열
*/
// audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템
// 화이트리스트(SafeTemplatePath + AllowedTemplateFileType)가 유일한 방어선이다.
// 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다.
// 확장 가능한 "동적 필드" 가 없는 요청이라 룰의 취지(필드 확장)도 해당하지 않는다.
public function rules(): array
{
// 템플릿 식별자로부터 기준 경로 구성
@@ -33,7 +42,7 @@ class ServeTemplateAssetRequest extends FormRequest
'required',
'string',
new SafeTemplatePath($basePath),
new AllowedTemplateFileType(),
new AllowedTemplateFileType,
],
];
}
@@ -43,10 +52,13 @@ class ServeTemplateAssetRequest extends FormRequest
*/
protected function prepareForValidation(): void
{
// 라우트 파라미터를 검증 데이터에 병합
// 라우트 파라미터를 검증 데이터에 병합.
// 확장자 없는 모드에서는 파일 경로가 경로 세그먼트가 아니라 `?file=` 쿼리로 온다
// (nginx 정적 최적화 블록이 URL 경로의 확장자만 보고 가로채는 것을 회피).
// 어느 형태로 오든 컨트롤러는 동일하게 `path` 만 본다.
$this->merge([
'identifier' => $this->route('identifier'),
'path' => $this->route('path'),
'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM),
]);
}
@@ -64,4 +76,4 @@ class ServeTemplateAssetRequest extends FormRequest
'path.string' => __('validation.asset.path.string'),
];
}
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Support\AssetUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
@@ -174,6 +175,13 @@ class SaveSettingsRequest extends FormRequest
'general.language' => $this->getTabRules($tab, 'general', [Rule::in(config('app.supported_locales', ['ko', 'en']))]),
'general.currency' => ['nullable', 'string', 'max:10'],
'general.maintenance_mode' => ['nullable', 'boolean'],
// 자산 URL 방식 — 정적 최적화 서버 대응 (이슈 #486).
// 임의 문자열이 들어오면 AssetUrl::mode() 가 기본값으로 폴백하지만,
// 저장 단계에서 막아 설정 파일에 뜻 모를 값이 남지 않게 한다.
'general.asset_url_mode' => ['nullable', 'string', Rule::in([
AssetUrl::MODE_EXTENSION,
AssetUrl::MODE_EXTENSIONLESS,
])],
'general.site_logo' => ['nullable', 'array'],
'general.site_logo.*' => ['integer', Rule::exists(Attachment::class, 'id')],
+2 -153
View File
@@ -8,17 +8,18 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class TemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
/**
@@ -115,156 +116,4 @@ class TemplateComposer
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
/**
* 확장 프론트엔드 병합 번들 URL을 계산합니다.
*
* 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
* 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
* (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
* 않는다(CDN 호스팅 금지).
*
* @param array $moduleAssets 수집된 모듈 개별 에셋 맵
* @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
* @param int $version 확장 캐시 버전
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
*/
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
{
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
return [
'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
*/
private function collectModuleAssets(): array
{
$assets = [];
try {
// ModuleManager에서 활성화된 모듈 목록 조회
$activeModules = $this->moduleManager->getActiveModules();
// 캐시 버전 조회 (브라우저 캐시 무효화용)
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
foreach ($activeModules as $identifier => $module) {
// 모듈에 에셋이 있는지 확인
if (! $module->hasAssets()) {
continue;
}
$builtPaths = $module->getBuiltAssetPaths();
$loadingConfig = $module->getAssetLoadingConfig();
$assetConfig = $module->getAssets();
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
if ($loadingConfig['strategy'] !== 'global') {
continue;
}
$moduleAsset = [
'priority' => $loadingConfig['priority'],
];
// JS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['js'])) {
$moduleAsset['js'] = "/api/modules/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
}
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['css'])) {
$moduleAsset['css'] = "/api/modules/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
}
// 외부 스크립트 (조건부 로드용)
if (! empty($assetConfig['external'])) {
$moduleAsset['external'] = $assetConfig['external'];
}
$assets[$identifier] = $moduleAsset;
}
// 우선순위 기준 정렬 (낮을수록 먼저)
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
} catch (\Exception $e) {
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
Log::warning('Failed to collect module assets: '.$e->getMessage());
}
return $assets;
}
/**
* 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
*/
private function collectPluginAssets(): array
{
$assets = [];
try {
// PluginManager에서 활성화된 플러그인 목록 조회
$activePlugins = $this->pluginManager->getActivePlugins();
// 캐시 버전 조회 (브라우저 캐시 무효화용)
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
foreach ($activePlugins as $identifier => $plugin) {
// 플러그인에 에셋이 있는지 확인
if (! $plugin->hasAssets()) {
continue;
}
$builtPaths = $plugin->getBuiltAssetPaths();
$loadingConfig = $plugin->getAssetLoadingConfig();
$assetConfig = $plugin->getAssets();
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
if ($loadingConfig['strategy'] !== 'global') {
continue;
}
$pluginAsset = [
'priority' => $loadingConfig['priority'],
];
// JS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['js'])) {
$pluginAsset['js'] = "/api/plugins/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
}
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['css'])) {
$pluginAsset['css'] = "/api/plugins/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
}
// 외부 스크립트 (조건부 로드용)
if (! empty($assetConfig['external'])) {
$pluginAsset['external'] = $assetConfig['external'];
}
$assets[$identifier] = $pluginAsset;
}
// 우선순위 기준 정렬 (낮을수록 먼저)
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
} catch (\Exception $e) {
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
Log::warning('Failed to collect plugin assets: '.$e->getMessage());
}
return $assets;
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Http\View\Composers\Traits;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Support\AssetUrl;
use Illuminate\Support\Facades\Log;
/**
* 활성 확장(모듈·플러그인)의 프론트엔드 에셋 수집 및 번들 URL 생성.
*
* `TemplateComposer`(admin)와 `UserTemplateComposer`(user)가 공유한다. 두 클래스는
* 이 세 메서드를 주석까지 통째로 중복 보유하고 있었고(123줄), 그래서 한쪽만 고치면
* 다른 쪽이 조용히 어긋나는 구조였다. 트레이트로 승격해 그 가능성을 제거한다.
*
* URL 생성은 전부 `AssetUrl` 을 경유한다 — 자산 URL 이중 모드(확장자 유지/제거)를
* 한 곳에서 결정하기 위함이다.
*
* 사용하는 클래스는 `$moduleManager` / `$pluginManager` 프로퍼티를 가져야 한다.
*
* @see AssetUrl
*/
trait CollectsExtensionAssets
{
/**
* 확장 병합 번들 URL 을 생성합니다.
*
* 병합 번들은 same-origin(`/api/...`)이어야 한다. 외부 origin/CDN 은 GDPR
* 프리블로커에 자기 차단되므로 호스팅하지 않는다.
*
* @param array<string, mixed> $moduleAssets 수집된 모듈 개별 에셋 맵
* @param array<string, mixed> $pluginAssets 수집된 플러그인 개별 에셋 맵
* @param int $version 확장 캐시 버전
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} 번들 URL 맵
*/
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
{
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
return [
'moduleJs' => $hasJs($moduleAssets) ? AssetUrl::extensionBundle('modules', 'js', $version) : null,
'moduleCss' => $hasCss($moduleAssets) ? AssetUrl::extensionBundle('modules', 'css', $version) : null,
'pluginJs' => $hasJs($pluginAssets) ? AssetUrl::extensionBundle('plugins', 'js', $version) : null,
'pluginCss' => $hasCss($pluginAssets) ? AssetUrl::extensionBundle('plugins', 'css', $version) : null,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}> 식별자별 에셋 맵
*/
private function collectModuleAssets(): array
{
return $this->collectExtensionAssets(
'modules',
fn () => $this->moduleManager->getActiveModules(),
'Failed to collect module assets: '
);
}
/**
* 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}> 식별자별 에셋 맵
*/
private function collectPluginAssets(): array
{
return $this->collectExtensionAssets(
'plugins',
fn () => $this->pluginManager->getActivePlugins(),
'Failed to collect plugin assets: '
);
}
/**
* 확장 에셋 수집 공통 구현.
*
* 모듈/플러그인은 수집 대상 조회 방법과 로그 문구만 다르고 나머지 로직이 동일하다.
*
* @param string $type `modules` 또는 `plugins`
* @param callable $activeResolver 활성 확장 목록을 반환하는 콜백
* @param string $logPrefix 실패 시 경고 로그 접두사
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}> 식별자별 에셋 맵
*/
private function collectExtensionAssets(string $type, callable $activeResolver, string $logPrefix): array
{
$assets = [];
try {
$active = $activeResolver();
// 캐시 버전 조회 (브라우저 캐시 무효화용)
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
foreach ($active as $identifier => $extension) {
// 확장에 에셋이 있는지 확인
if (! $extension->hasAssets()) {
continue;
}
$builtPaths = $extension->getBuiltAssetPaths();
$loadingConfig = $extension->getAssetLoadingConfig();
$assetConfig = $extension->getAssets();
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
if ($loadingConfig['strategy'] !== 'global') {
continue;
}
$asset = [
'priority' => $loadingConfig['priority'],
];
// JS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['js'])) {
$asset['js'] = AssetUrl::extensionAsset($type, $identifier, $builtPaths['js'], $cacheVersion);
}
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['css'])) {
$asset['css'] = AssetUrl::extensionAsset($type, $identifier, $builtPaths['css'], $cacheVersion);
}
// 외부 스크립트 (조건부 로드용)
if (! empty($assetConfig['external'])) {
$asset['external'] = $assetConfig['external'];
}
$assets[$identifier] = $asset;
}
// 우선순위 기준 정렬 (낮을수록 먼저)
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
} catch (\Exception $e) {
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
Log::warning($logPrefix.$e->getMessage());
}
return $assets;
}
}
@@ -8,12 +8,12 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
@@ -24,6 +24,7 @@ use Illuminate\View\View;
class UserTemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
/**
@@ -121,156 +122,4 @@ class UserTemplateComposer
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
/**
* 확장 프론트엔드 병합 번들 URL을 계산합니다.
*
* 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
* 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
* (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
* 않는다(CDN 호스팅 금지).
*
* @param array $moduleAssets 수집된 모듈 개별 에셋 맵
* @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
* @param int $version 확장 캐시 버전
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
*/
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
{
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
return [
'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
*/
private function collectModuleAssets(): array
{
$assets = [];
try {
// ModuleManager에서 활성화된 모듈 목록 조회
$activeModules = $this->moduleManager->getActiveModules();
// 캐시 버전 조회 (브라우저 캐시 무효화용)
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
foreach ($activeModules as $identifier => $module) {
// 모듈에 에셋이 있는지 확인
if (! $module->hasAssets()) {
continue;
}
$builtPaths = $module->getBuiltAssetPaths();
$loadingConfig = $module->getAssetLoadingConfig();
$assetConfig = $module->getAssets();
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
if ($loadingConfig['strategy'] !== 'global') {
continue;
}
$moduleAsset = [
'priority' => $loadingConfig['priority'],
];
// JS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['js'])) {
$moduleAsset['js'] = "/api/modules/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
}
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['css'])) {
$moduleAsset['css'] = "/api/modules/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
}
// 외부 스크립트 (조건부 로드용)
if (! empty($assetConfig['external'])) {
$moduleAsset['external'] = $assetConfig['external'];
}
$assets[$identifier] = $moduleAsset;
}
// 우선순위 기준 정렬 (낮을수록 먼저)
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
} catch (\Exception $e) {
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
Log::warning('Failed to collect module assets: '.$e->getMessage());
}
return $assets;
}
/**
* 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다.
*
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
*/
private function collectPluginAssets(): array
{
$assets = [];
try {
// PluginManager에서 활성화된 플러그인 목록 조회
$activePlugins = $this->pluginManager->getActivePlugins();
// 캐시 버전 조회 (브라우저 캐시 무효화용)
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
foreach ($activePlugins as $identifier => $plugin) {
// 플러그인에 에셋이 있는지 확인
if (! $plugin->hasAssets()) {
continue;
}
$builtPaths = $plugin->getBuiltAssetPaths();
$loadingConfig = $plugin->getAssetLoadingConfig();
$assetConfig = $plugin->getAssets();
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
if ($loadingConfig['strategy'] !== 'global') {
continue;
}
$pluginAsset = [
'priority' => $loadingConfig['priority'],
];
// JS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['js'])) {
$pluginAsset['js'] = "/api/plugins/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
}
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
if (! empty($builtPaths['css'])) {
$pluginAsset['css'] = "/api/plugins/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
}
// 외부 스크립트 (조건부 로드용)
if (! empty($assetConfig['external'])) {
$pluginAsset['external'] = $assetConfig['external'];
}
$assets[$identifier] = $pluginAsset;
}
// 우선순위 기준 정렬 (낮을수록 먼저)
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
} catch (\Exception $e) {
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
Log::warning('Failed to collect plugin assets: '.$e->getMessage());
}
return $assets;
}
}
+32 -13
View File
@@ -2,19 +2,31 @@
namespace App\Providers;
use App\Contracts\Extension\HookManagerInterface;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Http\View\Composers\TemplateComposer;
use App\Http\View\Composers\UserTemplateComposer;
use App\Listeners\ExtensionCompatibilityAlertListener;
use App\Notifications\NotificationChannelManager;
use App\Services\ChannelReadinessService;
use App\Services\GeoIpService;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Notifications\ChannelManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Laravel\Boost\BoostServiceProvider;
class AppServiceProvider extends ServiceProvider
{
@@ -23,48 +35,55 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
// 자산 URL 이중 모드 Route 매크로 (dualSuffix / dualAsset).
// boot() 가 아니라 register() 에서 등록하는 이유: 라우트 파일은 프레임워크의
// 라우팅 부트스트랩(boot 단계)에서 로드되므로, 프로바이더 간 boot 순서에
// 의존하면 매크로 미정의 시점에 라우트가 로드될 수 있다. 모든 프로바이더의
// register() 는 어떤 boot() 보다 먼저 실행되므로 여기가 유일하게 안전한 지점이다.
DualExtensionRoute::register();
// NOTE: Faker 부재 시 FakerShim 대체는 app/Support/SampleData/bootstrap.php 에서 처리
// (composer autoload.files 진입점 — vendor/autoload.php 로드 직후 실행되어
// Laravel 의 fake() 헬퍼 정의 시점에 \Faker\Factory 가 이미 alias 되어 있음)
// 알림 발송 공통 디스패처 — 채널 독립 발송 + 발송 전후 G7 훅 실행
$this->app->singleton(
\Illuminate\Notifications\ChannelManager::class,
fn ($app) => new \App\Notifications\NotificationChannelManager($app)
ChannelManager::class,
fn ($app) => new NotificationChannelManager($app)
);
// 채널 Readiness 검증 — 미설정 채널 발송 사전 차단
$this->app->singleton(
\App\Contracts\Notifications\ChannelReadinessCheckerInterface::class,
\App\Services\ChannelReadinessService::class
ChannelReadinessCheckerInterface::class,
ChannelReadinessService::class
);
// TODO: TemplateManagerInterface 바인딩을 추가해야 함
// PluginManagerInterface 바인딩
$this->app->bind(
\App\Contracts\Extension\PluginManagerInterface::class,
\App\Extension\PluginManager::class
PluginManagerInterface::class,
PluginManager::class
);
// ModuleManagerInterface 바인딩
$this->app->bind(
\App\Contracts\Extension\ModuleManagerInterface::class,
\App\Extension\ModuleManager::class
ModuleManagerInterface::class,
ModuleManager::class
);
// HookManagerInterface 바인딩
$this->app->bind(
\App\Contracts\Extension\HookManagerInterface::class,
\App\Extension\HookManager::class
HookManagerInterface::class,
HookManager::class
);
// GeoIpService 싱글톤 등록
$this->app->singleton(\App\Services\GeoIpService::class);
$this->app->singleton(GeoIpService::class);
// Laravel Boost (개발 전용 - dont-discover 대상, 클래스 존재 시에만 등록)
if (class_exists(\Laravel\Boost\BoostServiceProvider::class)) {
$this->app->register(\Laravel\Boost\BoostServiceProvider::class);
if (class_exists(BoostServiceProvider::class)) {
$this->app->register(BoostServiceProvider::class);
}
}
+4
View File
@@ -20,6 +20,10 @@ class AllowedModuleFileType implements ValidationRule
// Data
'json',
// Source maps — dev 빌드의 `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을
// 가리키므로 허용 필요. prod 는 ExtensionBundleService 가 참조 자체를 strip 한다.
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
+4
View File
@@ -20,6 +20,10 @@ class AllowedPluginFileType implements ValidationRule
// Data
'json',
// Source maps — dev 빌드의 `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을
// 가리키므로 허용 필요. prod 는 ExtensionBundleService 가 참조 자체를 strip 한다.
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
+8 -4
View File
@@ -20,6 +20,10 @@ class AllowedTemplateFileType implements ValidationRule
// Data
'json',
// Source maps — dev 빌드 산출물의 소스맵 참조 대응.
// prod 빌드는 소스맵 참조를 포함하지 않는다.
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
@@ -32,26 +36,26 @@ class AllowedTemplateFileType implements ValidationRule
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value)) {
if (! is_string($value)) {
$fail(__('validation.template_path.must_be_string'));
return;
}
$extension = strtolower(pathinfo($value, PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
$fail(__('validation.template_path.file_type_not_allowed', [
'extension' => $extension,
'allowed' => implode(', ', self::ALLOWED_EXTENSIONS),
]));
return;
}
}
/**
* 허용된 확장자 목록 반환
*
* @return array
*/
public static function getAllowedExtensions(): array
{
+2 -1
View File
@@ -12,6 +12,7 @@ use App\Services\LayoutService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\View;
@@ -689,7 +690,7 @@ class SeoRenderer implements SeoRendererInterface
foreach ($cssPaths as $cssPath) {
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
$servePath = preg_replace('#^dist/#', '', $cssPath);
$urls[] = '/api/templates/assets/'.$templateIdentifier.'/'.$servePath;
$urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath);
}
return $urls;
+6 -1
View File
@@ -7,6 +7,7 @@ use App\Extension\PluginManager;
use App\Extension\Storage\CoreStorageDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\TemplateComposer;
use App\Support\AssetUrl;
use Illuminate\Support\Facades\Log;
/**
@@ -414,7 +415,11 @@ class ExtensionBundleService
return preg_replace_callback($pattern, function (array $m) use ($typeSegment, $identifier) {
$mapFile = ltrim($m[1], './');
return '//# sourceMappingURL=/api/'.$typeSegment.'/assets/'.$identifier.'/dist/js/'.basename($mapFile);
return '//# sourceMappingURL='.AssetUrl::extensionAsset(
$typeSegment,
$identifier,
'dist/js/'.basename($mapFile)
);
}, $content) ?? $content;
}
+36
View File
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Support\ConfigCacheHelper;
use App\Support\OpcacheStatus;
use Illuminate\Support\Facades\Artisan;
@@ -44,6 +45,27 @@ class SettingsService
ConfigCacheHelper::rebuild();
}
/**
* 자산 URL 방식 변경에 따라 SEO 프리렌더 캐시를 비웁니다.
*
* SEO 캐시에는 생성 시점의 자산 URL 이 문자열로 구워져 있어, 모드가 바뀌면
* 그 URL 들이 전부 어긋난다. 사람 방문자는 브라우저 자가 복구가 살리지만
* 검색엔진 봇은 JavaScript 를 실행하지 않으므로 캐시를 비워 재생성시켜야 한다.
*
* 캐시 삭제 실패가 설정 저장 자체를 되돌리지는 않는다 — 설정은 이미 저장됐고,
* 캐시는 TTL 만료나 `seo:clear` 로도 회복 가능한 부수 상태다.
*/
private function clearSeoCacheForAssetUrlMode(): void
{
try {
app(SeoCacheManagerInterface::class)->clearAll();
} catch (\Throwable $e) {
Log::warning('자산 URL 방식 변경 후 SEO 캐시 삭제 실패 — seo:clear 로 수동 삭제 필요', [
'error' => $e->getMessage(),
]);
}
}
/**
* 모든 시스템 설정을 조회합니다.
*
@@ -476,12 +498,26 @@ class SettingsService
$existingSettings = $this->configRepository->getCategory($tab);
$mergedSettings = array_merge($existingSettings, $tabSettings);
// 자산 URL 방식이 바뀌는지 저장 **전에** 판정한다 (이슈 #486).
// 저장 후에는 이전 값을 알 수 없어 변경 여부를 판별할 수 없다.
$assetUrlModeChanged = $tab === 'general'
&& array_key_exists('asset_url_mode', $tabSettings)
&& ($existingSettings['asset_url_mode'] ?? null) !== $tabSettings['asset_url_mode'];
// 해당 카테고리 설정 저장
$result = $this->configRepository->saveCategory($tab, $mergedSettings);
if ($result) {
$this->invalidateSettingsCache();
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
// 않아 브라우저 자가 복구가 닿지 않는다 → 캐시를 비워 재생성시킨다.
// CLI(`g7:asset-url-mode`)와 동일한 처리 (계획서 §알려진 한계).
if ($assetUrlModeChanged) {
$this->clearSeoCacheForAssetUrlMode();
}
// drivers 탭은 queue/broadcasting/cache 등 long-running worker에 영향
// SettingsServiceProvider는 worker boot 시점에 한 번만 config 적용하므로
// 워커가 정상 종료 후 재시작되도록 신호 전송 (cache 기반, 즉시 종료 X)
+11 -5
View File
@@ -1739,13 +1739,19 @@ class TemplateService
*/
private function sanitizePath(string $path): string
{
// ../ 및 ..\ 패턴 제거
$path = str_replace(['../', '..\\'], '', $path);
// ../ 및 ..\ 패턴 제거 — 결과가 안정될 때까지 반복한다.
//
// 1회성 치환이면 제거 자체가 새 패턴을 만들어낸다:
// '....//' → 가운데 '../' 제거 → '../' (탈출 시퀀스 복원)
// 현재는 FormRequest 의 realpath 검사가 앞단에서 막고 있으나,
// 방어 계층이 하나 무력한 상태로 두지 않는다.
do {
$previous = $path;
$path = str_replace(['../', '..\\'], '', $path);
} while ($path !== $previous);
// 절대 경로 방지
$path = ltrim($path, '/\\');
return $path;
return ltrim($path, '/\\');
}
/**
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace App\Support;
use App\Support\Routing\DualExtensionRoute;
/**
* 자산·동적 엔드포인트 URL 생성기 (서버측 SSoT).
*
* ## 왜 필요한가
*
* G7 은 동적 API 엔드포인트에 정적 파일 확장자를 붙여 쓴다. 그런데 nginx/Apache 의
* 표준적 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 URL 마지막 확장자로
* 분기하며, nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로
* `try_files ... /index.php` 폴백이 실행될 기회가 없다. 그런 환경에서는 확장자 붙은
* 동적 응답이 PHP 에 도달하지 못하고 404 가 된다.
*
* 라우트는 이미 두 형태로 등록되어 있다(`DualExtensionRoute`). 남은 문제는 **URL 을
* 만드는 쪽**이 13개 지점에 흩어져 하드코딩되어 있었다는 것이다. 한 곳만 빠뜨려도
* 그 자산만 404 가 되고, 어느 지점이 빠졌는지는 화면이 죽어야 알 수 있다.
* 그래서 생성 경로를 여기 하나로 모은다.
*
* ## 모드
*
* `general.asset_url_mode` 설정값을 따른다.
*
* | 모드 | 의미 |
* |---|---|
* | `extension` (기본) | 확장자 유지 — 정상 환경. 확장자 기반 캐시/gzip 최적화를 보존한다 |
* | `extensionless` | 확장자 제거 — 정적 블록이 가로채는 환경 |
*
* 기본값이 `extension` 인 이유는 계획서 §"채택 방향" 을 따른다. 확장자를 일괄 제거하면
* `expires max` / `gzip_static` / CDN TTL 규칙이 함께 걸린 다수의 정상 환경에서
* 그 최적화를 전부 잃는다.
*
* @see DualExtensionRoute 라우트 이중 등록
*/
class AssetUrl
{
/**
* 확장자 유지 모드 식별자.
*/
public const MODE_EXTENSION = 'extension';
/**
* 확장자 제거 모드 식별자.
*/
public const MODE_EXTENSIONLESS = 'extensionless';
/**
* 확장자 없는 자산 URL 에서 파일 경로를 담는 쿼리 파라미터명.
*/
public const FILE_QUERY_PARAM = 'file';
/**
* 테스트/렌더 단위에서 모드를 강제하기 위한 오버라이드 값.
*
* null 이면 설정값을 조회한다.
*/
private static ?string $modeOverride = null;
/**
* 현재 자산 URL 모드를 반환합니다.
*
* 설정 조회가 실패해도(설치 전·마이그레이션 전 등) 예외를 던지지 않고
* 기본 모드로 폴백한다 — 이 값은 blade 렌더 경로에서 읽히므로 여기서
* 터지면 화면 전체가 죽는다.
*
* @return string `extension` 또는 `extensionless`
*/
public static function mode(): string
{
if (self::$modeOverride !== null) {
return self::$modeOverride;
}
try {
$mode = g7_core_settings('general.asset_url_mode', self::MODE_EXTENSION);
} catch (\Throwable $e) {
return self::MODE_EXTENSION;
}
return $mode === self::MODE_EXTENSIONLESS ? self::MODE_EXTENSIONLESS : self::MODE_EXTENSION;
}
/**
* 현재 모드가 확장자 없는 모드인지 여부를 반환합니다.
*
* @return bool 확장자 없는 모드이면 true
*/
public static function isExtensionless(): bool
{
return self::mode() === self::MODE_EXTENSIONLESS;
}
/**
* 모드를 강제로 지정합니다 (테스트 전용).
*
* @param string|null $mode 강제할 모드. null 이면 오버라이드 해제
*/
public static function forceMode(?string $mode): void
{
self::$modeOverride = $mode;
}
/**
* 템플릿 자산 URL 을 생성합니다.
*
* 템플릿은 서버가 `dist/` 를 자동 부가하므로 `$path` 는 `dist/` 를 포함하지 않는다
* (`TemplateService::getAssetFilePath`). 모듈/플러그인과 비대칭이므로 주의.
*
* @param string $identifier 템플릿 식별자
* @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function templateAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('templates', $identifier, $path, $version);
}
/**
* 모듈 자산 URL 을 생성합니다.
*
* 모듈은 모듈 루트 기준이라 `$path` 에 `dist/` 를 직접 포함해야 한다
* (`ModuleService::getAssetFilePath`).
*
* @param string $identifier 모듈 식별자
* @param string $path 모듈 루트 기준 파일 경로 (예: `dist/js/x.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function moduleAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('modules', $identifier, $path, $version);
}
/**
* 플러그인 자산 URL 을 생성합니다.
*
* @param string $identifier 플러그인 식별자
* @param string $path 플러그인 루트 기준 파일 경로 (예: `dist/js/x.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function pluginAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('plugins', $identifier, $path, $version);
}
/**
* 확장 타입을 인자로 받는 자산 URL 생성기.
*
* 모듈/플러그인을 같은 코드 경로로 처리하는 호출부(공용 트레이트 등)용.
* 타입이 컴파일 시점에 정해져 있으면 `moduleAsset()` / `pluginAsset()` 를 쓴다.
*
* @param string $type `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $path 파일 경로
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function extensionAsset(string $type, string $identifier, string $path, int|string|null $version = null): string
{
return self::asset($type, $identifier, $path, $version);
}
/**
* 확장 병합 번들 URL 을 생성합니다.
*
* 접미사(js/css)가 번들 종류를 구분하므로 제거할 수 없다.
* 확장자 없는 모드에서는 경로 세그먼트로 내린다 (`bundle.js` → `bundle/js`).
*
* @param string $type `modules` 또는 `plugins`
* @param string $kind `js` 또는 `css`
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function extensionBundle(string $type, string $kind, int|string|null $version = null): string
{
$base = self::isExtensionless()
? "/api/{$type}/bundle/{$kind}"
: "/api/{$type}/bundle.{$kind}";
return $base.self::versionQuery($version);
}
/**
* 고정 접미사를 갖는 동적 엔드포인트 URL 을 생성합니다.
*
* 확장자 없는 모드에서는 접미사를 제거한다 (`routes.json` → `routes`).
*
* @param string $path 접미사를 제외한 경로 (예: `/api/templates/foo/routes`)
* @param string $suffix 접미사 (예: `json`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function suffixed(string $path, string $suffix, int|string|null $version = null): string
{
$base = rtrim($path, '/');
$normalized = ltrim($suffix, '.');
$url = self::isExtensionless() ? $base : $base.'.'.$normalized;
return $url.self::versionQuery($version);
}
/**
* 확장 자산 URL 을 생성하는 공통 구현.
*
* 확장자 없는 모드에서는 파일 경로를 `?file=` 쿼리로 옮긴다. 경로가 곧 파일명이라
* 접미사만 떼어낼 수 없기 때문이며, nginx 의 location 정규식이 쿼리스트링을 제외한
* 경로에만 매칭되므로 이 형태가 안전하다.
*
* @param string $type `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $path 파일 경로
* @param int|string|null $version 캐시 무효화 버전
* @return string 생성된 URL
*/
private static function asset(string $type, string $identifier, string $path, int|string|null $version): string
{
$path = ltrim($path, '/');
if (! self::isExtensionless()) {
return "/api/{$type}/assets/{$identifier}/{$path}".self::versionQuery($version);
}
$query = self::FILE_QUERY_PARAM.'='.rawurlencode($path);
if ($version !== null && $version !== '') {
$query .= '&v='.$version;
}
return "/api/{$type}/assets/{$identifier}?{$query}";
}
/**
* 캐시 무효화 쿼리스트링을 생성합니다.
*
* @param int|string|null $version 버전 값
* @return string `?v=...` 또는 빈 문자열
*/
private static function versionQuery(int|string|null $version): string
{
if ($version === null || $version === '') {
return '';
}
return '?v='.$version;
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Support\Routing;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Route;
/**
* 동적 응답 라우트를 "확장자 형태"와 "확장자 없는 형태" 두 벌로 동시 등록하는 Route 매크로.
*
* ## 배경
*
* G7 은 동적 API 엔드포인트에 정적 파일 확장자를 붙여 쓴다 (`/api/templates/{id}/routes.json`).
* 그런데 nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며,
* nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로
* `try_files ... /index.php` 폴백이 실행될 기회 자체가 없다.
*
* ```nginx
* location ~* \.(js|css|json)$ { expires max; access_log off; }
* ```
*
* 이 설정 하에서는 PHP 로 요청이 넘어가지 않고 nginx 가 직접 파일시스템을 열려 시도해
* 404 가 되며, G7 은 부트스트랩 자산부터 죽는다. 관례를 깬 쪽이 G7 이므로 해소 책임도
* G7 에 있다는 판단으로, 두 형태를 모두 서빙하고 환경에 따라 택일한다.
*
* ## 규칙
*
* - 고정 접미사 엔드포인트는 접미사를 뗀다 (`routes.json` → `routes`)
* - 와일드카드 자산 라우트는 경로가 곧 파일명이라 뗄 수 없으므로 쿼리로 옮긴다
* (`assets/{id}/js/a.js` → `assets/{id}?file=js/a.js`).
* nginx 의 location 정규식은 쿼리스트링을 제외한 경로에만 매칭되므로 안전하다.
* - 두 형태 모두 영구 유지한다. 확장자 형태를 제거하면 URL 을 하드코딩한
* 서드파티 확장이 깨진다.
*
* ## 등록 순서
*
* 확장자 형태를 항상 **먼저** 등록한다. 확장자 없는 형태가 더 느슨한 패턴이라
* (예: `layouts/{tpl}/{layoutName}` 의 `layoutName` 정규식은 `.` 를 포함해 greedy)
* 순서가 뒤집히면 확장자 없는 라우트가 `.json` 요청까지 삼킨다.
*
* @see DualRouteProxy
*/
class DualExtensionRoute
{
/**
* 확장자 없는 자산 라우트에서 파일 경로를 담는 쿼리 파라미터명.
*/
public const FILE_QUERY_PARAM = 'file';
/**
* Route 매크로 3종(`dualSuffix` / `dualSuffixSegment` / `dualAsset`)을 등록합니다.
*
* `AppServiceProvider::register()` 에서 호출됩니다. boot() 가 아닌 이유:
* 라우트 파일은 프레임워크 라우팅 부트스트랩(boot 단계)에서 로드되므로,
* 프로바이더 간 boot 순서에 의존하면 매크로 미정의 시점에 라우트가 로드될 수 있다.
* 모든 프로바이더의 register() 는 어떤 boot() 보다 먼저 실행된다.
*/
public static function register(): void
{
static::registerDualSuffix();
static::registerDualSuffixSegment();
static::registerDualAsset();
}
/**
* `Route::dualSuffix()` 매크로를 등록합니다.
*
* 고정 접미사를 갖는 엔드포인트를 두 형태로 등록합니다.
*
* ```php
* Route::dualSuffix('templates/{identifier}/routes', 'json', [Ctrl::class, 'getRoutes'])
* ->name('api.public.templates.routes');
* // → GET templates/{identifier}/routes.json (name: api.public.templates.routes)
* // → GET templates/{identifier}/routes (name: api.public.templates.routes.extensionless)
* ```
*/
private static function registerDualSuffix(): void
{
Route::macro('dualSuffix', function (string $uri, string $suffix, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
// 확장자 형태를 먼저 등록 (더 구체적인 패턴이 우선 매칭되어야 함)
$extension = $this->get($base.'.'.ltrim($suffix, '.'), $action);
$extensionless = $this->get($base, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
/**
* `Route::dualSuffixSegment()` 매크로를 등록합니다.
*
* 접미사 자체가 의미를 담아 **뗄 수 없는** 엔드포인트용입니다.
* `bundle.js` 와 `bundle.css` 는 접미사를 제거하면 둘 다 `bundle` 이 되어 충돌하므로,
* 접미사를 삭제하는 대신 경로 세그먼트로 내린다.
*
* ```php
* Route::dualSuffixSegment('modules/bundle', 'js', [Ctrl::class, 'serveBundleJs'])
* ->name('api.public.modules.bundle.js');
* // → GET modules/bundle.js (name: api.public.modules.bundle.js)
* // → GET modules/bundle/js (name: api.public.modules.bundle.js.extensionless)
* ```
*/
private static function registerDualSuffixSegment(): void
{
Route::macro('dualSuffixSegment', function (string $uri, string $suffix, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
$normalized = ltrim($suffix, '.');
$extension = $this->get($base.'.'.$normalized, $action);
$extensionless = $this->get($base.'/'.$normalized, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
/**
* `Route::dualAsset()` 매크로를 등록합니다.
*
* 와일드카드 파일 경로를 갖는 자산 서빙 엔드포인트를 두 형태로 등록합니다.
* 확장자 형태는 `{path}` 세그먼트로, 확장자 없는 형태는 `?file=` 쿼리로 경로를 받으며
* 양쪽 모두 동일 컨트롤러로 들어갑니다 (흡수는 각 FormRequest 의
* `prepareForValidation()` 이 담당).
*
* ```php
* Route::dualAsset('templates/assets/{identifier}', [Ctrl::class, 'serveAsset'])
* ->name('api.public.templates.assets');
* // → GET templates/assets/{identifier}/{path} where path = .*
* // → GET templates/assets/{identifier} (?file=js/a.js)
* ```
*/
private static function registerDualAsset(): void
{
Route::macro('dualAsset', function (string $uri, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
// 확장자 형태를 먼저 등록 (경로 세그먼트가 더 구체적)
$extension = $this->get($base.'/{path}', $action)->where('path', '.*');
$extensionless = $this->get($base, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Support\Routing;
use Illuminate\Routing\Route;
/**
* 이중 등록된 두 라우트(확장자 형태 · 확장자 없는 형태)를 하나처럼 체이닝하기 위한 프록시.
*
* `Route::dualSuffix()` / `Route::dualAsset()` 매크로가 반환하며, `name()` 을 제외한
* 모든 호출(`middleware()`, `where()`, `defaults()` 등)을 양쪽 라우트에 그대로 전달한다.
* 한쪽에만 적용되어 조용히 어긋나는 상황을 구조적으로 차단하는 것이 목적이다.
*
* @mixin Route
*/
class DualRouteProxy
{
/**
* 확장자 없는 형태의 라우트 이름에 붙는 접미사.
*
* 두 라우트에 같은 이름을 주면 Laravel 의 이름 조회가 나중에 등록된 쪽으로
* 덮여 `route()` 결과가 조용히 바뀐다. 확장자 형태가 기존 이름을 유지하고
* 확장자 없는 형태만 접미사를 갖는다 (하위호환).
*/
public const EXTENSIONLESS_NAME_SUFFIX = '.extensionless';
/**
* @param Route $extension 확장자 형태 라우트 (예: `templates/{id}/routes.json`)
* @param Route $extensionless 확장자 없는 형태 라우트 (예: `templates/{id}/routes`)
*/
public function __construct(
private readonly Route $extension,
private readonly Route $extensionless,
) {}
/**
* 확장자 형태 라우트를 반환합니다.
*
* @return Route 확장자 형태 라우트 인스턴스
*/
public function extensionRoute(): Route
{
return $this->extension;
}
/**
* 확장자 없는 형태 라우트를 반환합니다.
*
* @return Route 확장자 없는 형태 라우트 인스턴스
*/
public function extensionlessRoute(): Route
{
return $this->extensionless;
}
/**
* 두 라우트에 이름을 부여합니다.
*
* 확장자 형태는 전달된 이름을 그대로, 확장자 없는 형태는
* `.extensionless` 접미사를 붙여 등록합니다.
*
* @param string $name 라우트 이름
* @return self 체이닝을 위한 자기 자신
*/
public function name(string $name): self
{
$this->extension->name($name);
$this->extensionless->name($name.self::EXTENSIONLESS_NAME_SUFFIX);
return $this;
}
/**
* 그 외 모든 호출을 두 라우트에 동일하게 전달합니다.
*
* @param string $method 호출된 메서드명
* @param array<int, mixed> $arguments 전달 인자
* @return self 체이닝을 위한 자기 자신
*/
public function __call(string $method, array $arguments): self
{
$this->extension->{$method}(...$arguments);
$this->extensionless->{$method}(...$arguments);
return $this;
}
}
+4 -2
View File
@@ -14,7 +14,8 @@
"language": "ko",
"currency": "KRW",
"maintenance_mode": false,
"site_logo": []
"site_logo": [],
"asset_url_mode": "extension"
},
"security": {
"force_https": false,
@@ -174,7 +175,8 @@
"language": { "type": "string", "sensitive": false, "expose": false },
"timezone": { "type": "string", "sensitive": false, "expose": false },
"site_logo": { "type": "array", "sensitive": false, "transform": "attachments" },
"site_logo_url": { "type": "string", "sensitive": false, "computed": true, "source": "site_logo", "transform": "first_image_url" }
"site_logo_url": { "type": "string", "sensitive": false, "computed": true, "source": "site_logo", "transform": "first_image_url" },
"asset_url_mode": { "type": "string", "sensitive": false }
}
},
"security": {
+46
View File
@@ -105,6 +105,52 @@ Authorization: Bearer {YOUR_TOKEN}
}
```
### 자산 URL 이중 모드
정적 파일 확장자(`.js` / `.css` / `.json`)로 끝나는 동적 엔드포인트는 **확장자 없는 형태를 함께 제공**합니다.
아래 문서에 실린 URI 는 확장자 형태를 기준으로 표기하지만, 각 엔드포인트는 대응하는 확장자 없는 형태로도
동일한 응답·동일한 권한 가드로 호출할 수 있습니다.
이유는 서버 설정입니다. nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며,
nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로 `try_files ... /index.php` 폴백이
실행될 기회가 없습니다. 그런 환경에서는 확장자 붙은 동적 응답이 PHP 에 도달하지 못하고 404 가 됩니다.
```nginx
location ~* \.(js|css|json)$ { expires max; access_log off; }
```
| 확장자 형태 | 확장자 없는 형태 | 변환 규칙 |
| --- | --- | --- |
| `/api/templates/{id}/routes.json` | `/api/templates/{id}/routes` | 접미사 제거 |
| `/api/layouts/{tpl}/{layout}.json` | `/api/layouts/{tpl}/{layout}` | 접미사 제거 |
| `/api/modules/bundle.js` | `/api/modules/bundle/js` | 접미사를 경로 세그먼트로 (js/css 구분이 필요) |
| `/api/templates/assets/{id}/js/a.js` | `/api/templates/assets/{id}?file=js/a.js` | 파일 경로를 `file` 쿼리로 (경로가 곧 파일명이라 제거 불가) |
`file` 쿼리 형태가 안전한 이유는 nginx 의 location 정규식이 쿼리스트링을 제외한 경로에만 매칭되기 때문입니다.
확장자 없는 형태에도 경로 탈출 방어와 확장자 화이트리스트가 동일하게 적용됩니다.
두 형태는 **모두 영구 유지**됩니다. 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨집니다.
어느 형태를 쓸지는 서버 환경에 따라 결정되며, 다음 프로브 엔드포인트로 판정합니다.
| 메서드 | URI | 인증/권한 | 설명 |
| --- | --- | --- | --- |
| GET | `/api/system/asset-probe.js` | 공개 (인증 불필요) | 확장자 형태 프로브 |
| GET | `/api/system/asset-probe` | 공개 (인증 불필요) | 대조군 |
두 URL 을 **브라우저에서** 쌍으로 요청합니다(서버측 loopback curl 은 vhost·프록시 체인을 우회해 오판합니다).
응답은 `application/javascript` 이며 본문에 매직 토큰 `G7_ASSET_PROBE_OK` 를 담습니다. DB 에 접근하지 않고
`Cache-Control: no-store` 로 캐시되지 않습니다.
| `asset-probe.js` | `asset-probe` | 판정 |
| --- | --- | --- |
| 성공 | 성공 | 확장자 형태 사용 가능 |
| 실패 | 성공 | 정적 블록 가로채기 확정 — 확장자 없는 형태 사용 |
| 실패 | 실패 | 모드 문제가 아님 (PHP/라우팅 장애) |
성공 판정은 상태코드가 아니라 **본문의 매직 토큰과 Content-Type** 으로 합니다. 상태코드만 보면
"404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서 영원히 오판합니다.
## 코어 API 레퍼런스
<!-- @generated:start:api-readme-index -->
+4 -2
View File
@@ -821,7 +821,7 @@ Authorization: Bearer {YOUR_TOKEN}
**설명** 데이터소스 websocket 후보용으로 등록된 브로드캐스트 채널/이벤트 카탈로그를 반환합니다(BroadcastCatalogService::collect). 편집기 전용 가드 하에서만 노출되며(admin 전역 broadcast 회피), `identifier`는 라우트 일관성용이고 카탈로그는 설치본 전역 기준입니다. `core.templates.layouts.edit` 권한이 필요합니다.
### GET /api/admin/templates/{identifier}/editor/components.css
### GET /api/admin/templates/{identifier}/editor/component-styles.css
<!-- @generated:start:api.admin.templates.editor-css -->
- **라우트명**: `api.admin.templates.editor-css`
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveEditorCss`
@@ -836,7 +836,7 @@ Authorization: Bearer {YOUR_TOKEN}
**요청 예시**
```http
GET /api/admin/templates/{identifier}/editor/components.css HTTP/1.1
GET /api/admin/templates/{identifier}/editor/component-styles.css HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
@@ -862,6 +862,8 @@ Authorization: Bearer {YOUR_TOKEN}
**설명** 레이아웃 편집기 프리뷰 전용 CSS를 서빙합니다. 편집기 진입 시에만 components.css의 다크 조상 셀렉터를 프리뷰 마커로 치환하고(editor-spec `darkMode.previewIsolation` 규칙), 필요 시 `@layer` 래퍼를 평탄화해 라이트/다크 프리뷰를 격리합니다. 변환 결과는 캐시 버전+파일 mtime 키로 캐시하며, CSS 부재 시 빈 응답으로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다.
URI 가 `editor/components.css` 가 아니라 `editor/component-styles.css` 인 이유는 [자산 URL 이중 모드](README.md#자산-url-이중-모드) 때문입니다. 확장자를 뗀 형태(`editor/components`)가 `editor/components.json` 의 확장자 없는 형태와 충돌하므로 CSS 쪽 URI 를 분리했습니다. 확장자 없는 형태는 `editor/component-styles` 입니다.
### GET /api/admin/templates/{identifier}/editor/components.json
<!-- @generated:start:api.admin.templates.editor-components -->
+46
View File
@@ -12,6 +12,7 @@
3. URL: /api/admin/*, /api/auth/*, /api/public/*
4. 권한: permission: 또는 Middleware에서 체크
5. REST 패턴: index, store, show, update, destroy
6. .js/.css/.json/.map 로 끝나는 라우트 = dualSuffix/dualSuffixSegment/dualAsset 매크로 필수
```
---
@@ -101,6 +102,51 @@ GET /api/admin/plugins/{identifier}/license # 플러그인 LICENSE 반환
GET /api/admin/templates/{identifier}/license # 템플릿 LICENSE 반환
```
### 정적 확장자로 끝나는 동적 엔드포인트
`.js` / `.css` / `.json` / `.map` 으로 끝나는 라우트는 `Route::get()` 으로 단일 등록하지 않는다.
`Route::dualSuffix()` / `dualSuffixSegment()` / `dualAsset()` 매크로로 **확장자 형태와 확장자 없는 형태를
동시에** 등록한다. 코어 라우트와 확장 라우트 파일 모두에 적용된다.
nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며, nginx 에서 정규식 location 은
프리픽스 location 보다 먼저 매칭된다. 그래서 `try_files ... /index.php` 폴백이 실행될 기회 없이 nginx 가
직접 파일시스템을 열려 시도해 404 가 된다. aaPanel / CyberPanel / Plesk 기본 템플릿에 들어있는 블록이라
드물지 않으며, 그런 서버에서는 해당 엔드포인트가 통째로 죽는다.
```nginx
location ~* \.(js|css|json)$ { expires max; access_log off; }
```
```php
// 잘못된 등록 — 확장자 없는 형태가 생기지 않는다
Route::get('{identifier}/routes.json', [Ctrl::class, 'getRoutes'])->name('...');
// 접미사 제거형 — routes.json + routes
Route::dualSuffix('{identifier}/routes', 'json', [Ctrl::class, 'getRoutes'])
->name('api.public.templates.routes');
// 세그먼트 강등형 — 접미사가 종류를 구분해 제거 불가 (bundle.js + bundle/js)
Route::dualSuffixSegment('bundle', 'js', [Ctrl::class, 'serveBundleJs'])
->name('api.public.modules.bundle.js');
// 쿼리 이동형 — 경로가 곧 파일명 (assets/{id}/js/a.js + assets/{id}?file=js/a.js)
Route::dualAsset('assets/{identifier}', [Ctrl::class, 'serveAsset'])
->name('api.public.templates.assets');
```
매크로가 반환하는 프록시는 `name()` 을 양쪽에 적용하며(확장자 없는 쪽은 `.extensionless` 접미사),
`middleware()` 등 나머지 호출도 두 라우트에 함께 전달한다. 한쪽에만 걸리는 가드가 생기지 않는다.
URL 을 만드는 쪽도 문자열로 직접 조립하지 않는다. 서버는 `App\Support\AssetUrl`, 프론트엔드는
`resources/js/core/support/assetUrl.ts` 를 경유해야 현재 모드에 맞는 형태가 나온다. 두 구현은 동일 규칙을
공유하므로 한쪽만 바꾸면 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 된다.
자동 차단: audit 룰 `dynamic-route-static-extension` (error). 면제는 인라인 주석
`// audit:allow dynamic-route-static-extension reason: ...`.
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
---
## 권한 체크
+1 -1
View File
@@ -608,7 +608,7 @@ Tailwind 는 빌드 시 safelist 에 없는 임의값 클래스(`dark:text-[#hex
}
```
코어 CSS 서빙 API(`/api/admin/templates/{id}/editor/components.css`)가 편집기 진입
코어 CSS 서빙 API(`/api/admin/templates/{id}/editor/component-styles.css`)가 편집기 진입
시에만 이 선언대로 CSS 의 다크 조상 셀렉터(`rewriteSelector`)를 프리뷰 전용 마커
(`replaceWith`)로 치환해 서빙한다. 일반 사용자 페이지 CSS 는 원본 그대로다
(사용자 페이지 무영향). `strategy: "none"` 또는 미선언이면 편집기 다크 탭이 비노출된다.
@@ -22,6 +22,8 @@
### Added
- 자산 파일 주소 방식 확인·전환 명령(`g7:asset-url-mode`) 출력 문구 일본어 번역 추가 (`asset_url_mode.*`) — 현재 방식과 서버 진단 안내, 전환 결과 메시지가 일본어 로케일에서 자연스럽게 표시됩니다.
- 데이터베이스 계정 설정 오류 안내 화면의 문구 일본어 번역 추가 (`database_credential.*`) — 최고 권한 계정을 사용하거나 사용자명이 비어 있을 때 표시되는 안내와 복구 방법이 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
@@ -0,0 +1,17 @@
<?php
return [
'current' => '現在のアセット URL 方式: :mode',
'table' => [
'in_use' => '使用中',
'alternative' => '変換時',
],
'diagnose_title' => 'サーバーが動的レスポンスをインターセプトしているかどうかを確認するには、以下の 2 つのリクエストを比較してください:',
'diagnose_hint' => '最初のリクエストのみ失敗し、2 番目が成功する場合は、静的最適化設定がインターセプトしています → 拡張子なしに変換してください。',
'switch_hint' => '変換: php artisan g7:asset-url-mode extensionless',
'invalid' => '不明な方式です: :mode (extension または extensionless)',
'already' => '既に :mode 方式を使用中です。',
'switched' => 'アセット URL 方式を :from → :to に変更しました。',
'save_failed' => '設定の保存に失敗しました。',
'seo_cache_cleared' => 'SEO キャッシュも一緒にクリアしました (焼き込まれたアセットアドレスが古い方式のため)。',
];
@@ -8,6 +8,8 @@
### Added
- 일반 설정의 자산 파일 주소 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지가 일본어 로케일에서 자연스럽게 표시됩니다.
- 환경설정 > 정보 화면에 추가된 OPcache 상태 항목과 비활성 안내 문구 일본어 번역 추가 — 상태 표시와 성능 저하 안내가 일본어 로케일에서도 올바르게 표시됩니다. 비활성 상태 문구는 성능 저하의 체감 정도가 드러나도록 보강했습니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
@@ -1311,7 +1311,16 @@
"default_language": "デフォルト言語",
"select_language": "言語を選択してください",
"timezone": "タイムゾーン",
"select_timezone": "タイムゾーンを選択してください"
"select_timezone": "タイムゾーンを選択してください",
"asset_url_mode": "アセットファイルアドレス方式",
"asset_url_mode_help": "サーバー設定に応じて自動的に選択されます。画面が正常に表示されている場合は変更しないでください。",
"asset_url_mode_extension": "拡張子を使用 (推奨)",
"asset_url_mode_extensionless": "拡張子を使用しない",
"asset_url_mode_detect": "自動検出",
"asset_url_mode_detecting": "アセットアドレス方式を検出中です...",
"asset_url_mode_detect_extension": "拡張子を使用できる環境です。",
"asset_url_mode_detect_extensionless": "サーバーが拡張子アドレスをインターセプトしています。拡張子未使用に変更してから保存してください。",
"asset_url_mode_detect_unavailable": "検出に失敗しました。サーバーが応答しないため、方式を判定できません。"
},
"mail": {
"smtp_settings": "SMTP設定",
+22
View File
@@ -0,0 +1,22 @@
<?php
// 주의: 관리자 화면 문구는 여기 두지 않는다 — 템플릿 lang(admin.json)이 SSoT.
// 이 파일은 Artisan 커맨드(g7:asset-url-mode) 출력 전용이다.
return [
// CLI status
'current' => 'Current asset URL style: :mode',
'table' => [
'in_use' => 'In use',
'alternative' => 'After switching',
],
'diagnose_title' => 'To check whether your server intercepts dynamic responses, compare these two requests:',
'diagnose_hint' => 'If only the first one fails while the second succeeds, a static-optimization rule is intercepting it. Switch to extensionless.',
'switch_hint' => 'Switch with: php artisan g7:asset-url-mode extensionless',
// CLI switch
'invalid' => 'Unknown style: :mode (use extension or extensionless)',
'already' => 'Already using the :mode style.',
'switched' => 'Asset URL style changed from :from to :to.',
'save_failed' => 'Failed to save the setting.',
'seo_cache_cleared' => 'SEO cache was cleared as well (baked asset URLs used the old style).',
];
+22
View File
@@ -0,0 +1,22 @@
<?php
// 주의: 관리자 화면 문구는 여기 두지 않는다 — 템플릿 lang(admin.json)이 SSoT.
// 이 파일은 Artisan 커맨드(g7:asset-url-mode) 출력 전용이다.
return [
// CLI 조회
'current' => '현재 자산 URL 방식: :mode',
'table' => [
'in_use' => '사용 중',
'alternative' => '전환 시',
],
'diagnose_title' => '서버가 동적 응답을 가로채는지 확인하려면 아래 두 요청을 비교하세요:',
'diagnose_hint' => '첫 번째만 실패하고 두 번째가 성공하면 정적 최적화 설정이 가로채는 것입니다 → extensionless 로 전환하세요.',
'switch_hint' => '전환: php artisan g7:asset-url-mode extensionless',
// CLI 전환
'invalid' => '알 수 없는 방식입니다: :mode (extension 또는 extensionless)',
'already' => '이미 :mode 방식을 사용 중입니다.',
'switched' => '자산 URL 방식을 :from → :to 로 변경했습니다.',
'save_failed' => '설정 저장에 실패했습니다.',
'seo_cache_cleared' => 'SEO 캐시를 함께 비웠습니다 (구운 자산 주소가 옛 방식이므로).',
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+83
View File
@@ -1554,11 +1554,94 @@
/**
* 실시간 검증 초기화
*/
/**
* 자산 URL 방식을 브라우저에서 판정해 hidden 필드에 채운다 (이슈 #486 §6·§7).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라 프리픽스
* location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 응답이
* PHP 에 도달하지 못한 채 404 가 된다. 설치 시점에 판정해 두지 않으면 설치 직후
* 첫 화면부터 백지가 된다.
*
* 판정은 **쌍으로** 던진다. 단일 프로브는 "PHP 자체가 죽음" 과 구분되지 않는다.
*
* probe.js 실패 + probe 성공 = 정적 블록 가로채기 확정 → extensionless
* 둘 다 성공 = extension
* 둘 다 실패 = PHP/라우팅 문제 (모드 문제 아님) → 판정 보류
*
* 성공 판정은 상태코드가 아니라 **본문의 매직 토큰**으로 한다. 상태코드만 보면
* "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서
* 영원히 오판한다.
*/
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
const TOKEN = 'G7_ASSET_PROBE_OK';
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
/**
* 프로브 1건을 던져 매직 토큰 포함 여부를 반환한다.
*/
const probe = async (path) => {
try {
const res = await fetch(`${base}${path}`, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) return false;
// Content-Type 도 함께 본다 (L6). 토큰만 검사해도 200+HTML 오판은
// 걸러지지만, 계획서는 두 신호를 모두 요구한다.
const contentType = res.headers.get('content-type') || '';
if (!/javascript|ecmascript/i.test(contentType)) return false;
return (await res.text()).includes(TOKEN);
} catch (e) {
return false;
}
};
const [withExt, withoutExt] = await Promise.all([
probe('/api/system/asset-probe.js'),
probe('/api/system/asset-probe'),
]);
let detected = '';
if (withExt && withoutExt) {
detected = 'extension';
} else if (!withExt && withoutExt) {
detected = 'extensionless';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 값으로 두어 기본값을 따르게 한다.
field.value = detected;
// 수동 override — 감지 결과를 기본 선택으로 채우고 관리자가 바꿀 수 있게 한다.
// 프로브가 CSP/프록시 등으로 둘 다 실패하면 자동 판정이 불가하므로,
// 손댈 수단이 없으면 설치를 마친 뒤에야 문제를 알게 된다.
const select = document.getElementById('asset_url_mode_select');
const status = document.getElementById('asset_url_mode_status');
if (select) {
select.value = detected || 'extension';
select.addEventListener('change', function () {
field.value = this.value;
});
// 감지 실패 시에도 select 값이 hidden 에 반영되도록 초기 동기화
field.value = select.value;
}
if (status) {
status.textContent = status.getAttribute(
detected ? `data-msg-${detected}` : 'data-msg-unavailable'
) || '';
status.classList.remove('hidden');
}
}
function initRealTimeValidation() {
// Step 3 (config-form)이 있는 경우에만 실행
const configForm = document.getElementById('config-form');
if (!configForm) return;
// 자산 URL 방식 자동 감지 (비차단 — 실패해도 설치 진행에 지장 없음)
detectAssetUrlMode();
const fieldsToValidate = [
'app_name',
'app_url',
+12 -1
View File
@@ -1,5 +1,7 @@
<?php
use App\Support\PrivilegedDatabaseAccounts;
/**
* 그누보드7 웹 인스톨러 요청 처리 핸들러
*
@@ -140,7 +142,7 @@ function validateDbUsername(string $username, string $field, array &$errors): vo
return;
}
if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) {
if (PrivilegedDatabaseAccounts::isBlocked($username)) {
$errors[$field] = lang('error_db_username_privileged', ['username' => $username]);
}
}
@@ -205,6 +207,15 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
}
$formData['vendor_mode'] = $vendorMode;
// 자산 URL 방식 (이슈 #486) — Step 3 의 브라우저 프로브가 채운 hidden 필드.
// 정적 최적화 블록이 있는 서버는 확장자 붙은 동적 응답이 PHP 에 도달하지 못하므로
// 설치 시점에 확장자 없는 형태로 확정해야 첫 화면부터 정상 동작한다.
// 판정 불가(프로브 실패·JS 미실행)면 키를 비워 defaults.json 기본값을 따르게 한다.
$assetUrlMode = trim($formData['asset_url_mode'] ?? '');
$formData['asset_url_mode'] = in_array($assetUrlMode, ['extension', 'extensionless'], true)
? $assetUrlMode
: '';
// 코어 업데이트 _pending 경로 검증 (입력된 경우만)
$corePendingPath = trim($formData['core_update_pending_path'] ?? '');
if ($corePendingPath !== '') {
+8
View File
@@ -1657,6 +1657,14 @@ if (! function_exists('createSettingsJsonSSE')) {
$defaults['general']['language'] = getCurrentLanguage();
// 자산 URL 방식 (이슈 #486) — 설치 화면이 브라우저에서 프로브를 던져 판정한 결과.
// 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버는 확장자 붙은
// 동적 응답이 PHP 에 도달하지 못하므로 확장자 없는 형태로 설치를 마쳐야 한다.
// 미판정(구버전 설치 화면·프로브 실패)이면 defaults.json 의 기본값을 그대로 둔다.
if (in_array($config['asset_url_mode'] ?? null, ['extension', 'extensionless'], true)) {
$defaults['general']['asset_url_mode'] = $config['asset_url_mode'];
}
foreach ($categories as $category) {
if (! isset($defaults[$category])) {
sendSSEEvent('log', ['message' => " - {$category}.json skipped (no defaults)"]);
+13 -4
View File
@@ -1,4 +1,5 @@
<?php
// /install/lang/en.php
return [
@@ -424,7 +425,7 @@ return [
'abort_rollback_success' => '[Aborted] Rollback completed: :message',
'abort_rollback_failed' => '[Aborted] Rollback failed: :message (continuing)',
'abort_no_rollback_needed' => '[Aborted] No rollback needed. (current_task is null or already completed)',
'abort_by_user' => "[Aborted] User aborted installation. (Current task: :task)",
'abort_by_user' => '[Aborted] User aborted installation. (Current task: :task)',
'abort_installation_stopped' => 'Installation aborted.',
// Worker Failed Task Rollback Messages
@@ -548,7 +549,7 @@ return [
'validation_incomplete_title' => 'Please complete the following items:',
'confirm_leave_page' => 'Settings have not been saved. Are you sure you want to leave this page?',
'installation_in_progress_alert' => 'Installation is in progress. Do you want to go back to the settings page?',
'confirm_go_to_settings' => "Do you want to go back to the settings page?",
'confirm_go_to_settings' => 'Do you want to go back to the settings page?',
'confirm_go_to_settings_simple' => "Do you want to go back to the settings page?\n\nInstallation state will be reset and all tasks will start from the beginning.\n\n⚠️ Database tables will NOT be deleted automatically.\nPlease clean up manually using phpMyAdmin if needed.",
'confirm_go_to_settings_title' => 'Go to Settings Page',
'confirm_go_to_settings_desc' => 'Installation state will be reset and all tasks will start from the beginning.\n\n⚠️ Database tables will NOT be deleted automatically. Please clean up manually using phpMyAdmin if needed.',
@@ -712,7 +713,7 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'deselect_all' => 'Deselect All',
// install-worker.php i18n keys
'db_task_abort_detected_before_start' => '[DB Task] Abort detected before start - skipping task.',
'db_task_abort_detected_before_start' => '[DB Task] Abort detected before start - skipping task.',
'db_task_failed_rollback_start' => '[DB Task] :task failed - starting rollback.',
'db_task_abort_reason_connection' => 'Connection lost',
'db_task_abort_reason_user' => 'User requested',
@@ -831,6 +832,15 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'core_update_settings' => 'Core Update Settings (Optional)',
'core_update_pending_path' => 'Update Pending Directory Path',
'core_update_pending_path_help' => 'Leave empty to use the default (storage/app/core_pending). Enter an absolute path or a path relative to the Gnuboard7 root to use a custom location.',
// 자산 URL 방식 (이슈 #486)
'asset_url_mode' => 'Asset file URL style',
'asset_url_mode_extension' => 'Use file extensions (recommended)',
'asset_url_mode_extensionless' => 'No file extensions',
'asset_url_mode_help' => 'Detected automatically from your server setup. Some servers intercept .js/.css/.json URLs and pages fail to load; choose no extensions in that case.',
'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.',
'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',
@@ -915,4 +925,3 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
// Relative path alternative
'or_relative_path' => 'Or from the G7 root directory:',
];
?>
+13 -4
View File
@@ -1,4 +1,5 @@
<?php
// /install/lang/ko.php
return [
@@ -424,7 +425,7 @@ return [
'abort_rollback_success' => '[중단] 롤백 완료: :message',
'abort_rollback_failed' => '[중단] 롤백 실패: :message (계속 진행)',
'abort_no_rollback_needed' => '[중단] 롤백할 작업이 없습니다. (current_task가 null이거나 이미 완료됨)',
'abort_by_user' => "[중단] 사용자가 설치를 중단했습니다. (현재 작업: :task)",
'abort_by_user' => '[중단] 사용자가 설치를 중단했습니다. (현재 작업: :task)',
'abort_installation_stopped' => '설치가 중단되었습니다.',
// Worker 실패 시 롤백 관련 메시지
@@ -548,7 +549,7 @@ return [
'validation_incomplete_title' => '다음 항목을 완료해주세요:',
'confirm_leave_page' => '설정이 저장되지 않았습니다. 페이지를 나가시겠습니까?',
'installation_in_progress_alert' => '설치가 진행 중입니다. 설정 페이지로 돌아가시겠습니까?',
'confirm_go_to_settings' => "설정 페이지로 이동하시겠습니까?",
'confirm_go_to_settings' => '설정 페이지로 이동하시겠습니까?',
'confirm_go_to_settings_simple' => "설정 페이지로 이동하시겠습니까?\n\n설치 상태가 초기화되며, 모든 작업이 처음부터 다시 실행됩니다.\n\n⚠️ 데이터베이스에 생성된 테이블은 자동으로 삭제되지 않습니다.\n필요 시 phpMyAdmin 등을 통해 수동으로 정리해주세요.",
'confirm_go_to_settings_title' => '설정 페이지로 이동',
'confirm_go_to_settings_desc' => '설치 상태가 초기화되며, 모든 작업이 처음부터 다시 실행됩니다.\n\n⚠️ 데이터베이스에 생성된 테이블은 자동으로 삭제되지 않습니다. 필요 시 phpMyAdmin 등을 통해 수동으로 정리해주세요.',
@@ -712,7 +713,7 @@ ini_set(\'zlib.output_compression\', \'off\');
'deselect_all' => '전체 해제',
// install-worker.php 다국어 키
'db_task_abort_detected_before_start' => '[DB 작업] 시작 전 중단 상태 감지 - 작업을 건너뜁니다.',
'db_task_abort_detected_before_start' => '[DB 작업] 시작 전 중단 상태 감지 - 작업을 건너뜁니다.',
'db_task_failed_rollback_start' => '[DB 작업] :task 실패 - 롤백을 시작합니다.',
'db_task_abort_reason_connection' => '연결 끊김',
'db_task_abort_reason_user' => '사용자 요청',
@@ -831,6 +832,15 @@ ini_set(\'zlib.output_compression\', \'off\');
'core_update_settings' => '코어 업데이트 설정 (선택)',
'core_update_pending_path' => '업데이트 대기 디렉토리 경로',
'core_update_pending_path_help' => '비워두면 기본값(storage/app/core_pending)을 사용합니다. 외부 경로를 사용하려면 절대 경로 또는 그누보드7 루트 기준 상대 경로를 입력하세요.',
// 자산 URL 방식 (이슈 #486)
'asset_url_mode' => '자산 파일 주소 방식',
'asset_url_mode_extension' => '확장자 사용 (권장)',
'asset_url_mode_extensionless' => '확장자 미사용',
'asset_url_mode_help' => '서버 설정을 자동으로 감지해 선택합니다. 일부 서버는 .js/.css/.json 주소를 가로채 화면이 뜨지 않는데, 그 경우 확장자 미사용을 선택하세요.',
'asset_url_mode_detected_extension' => '감지 결과: 확장자를 사용할 수 있는 환경입니다.',
'asset_url_mode_detected_extensionless' => '감지 결과: 서버가 확장자 주소를 가로채고 있어 확장자 미사용을 선택했습니다.',
'asset_url_mode_detected_unavailable' => '감지하지 못했습니다. 설치 후 화면이 뜨지 않으면 확장자 미사용으로 바꿔 보세요.',
'core_update_github_url' => 'GitHub 저장소 URL',
'core_update_github_url_help' => '코어 업데이트를 확인할 GitHub 저장소 URL입니다.',
'core_update_github_token' => 'GitHub 액세스 토큰',
@@ -915,4 +925,3 @@ ini_set(\'zlib.output_compression\', \'off\');
// 상대경로 병기 안내
'or_relative_path' => '또는 그누보드7 루트 디렉토리에서:',
];
?>
+76 -56
View File
@@ -5,8 +5,7 @@
* 데이터베이스 설정, 사이트 설정, 관리자 계정을 입력받습니다.
* $formData는 index.php에서 준비됨
*/
if (!isset($errors)) {
if (! isset($errors)) {
$errors = [];
}
@@ -22,15 +21,15 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<div class="installer-container installer-container-wide">
<h1 class="installer-title"><?= htmlspecialchars(lang('step_3_configuration')) ?></h1>
<?php if (!empty($errors)): ?>
<?php if (! empty($errors)) { ?>
<div class="alert alert-error">
<ul class="alert-list">
<?php foreach ($errors as $error): ?>
<?php foreach ($errors as $error) { ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
<?php } ?>
</ul>
</div>
<?php endif; ?>
<?php } ?>
<form method="POST" id="config-form" class="installer-form">
<!-- 데이터베이스 설정 (Write DB) -->
@@ -116,7 +115,7 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<h3 class="requirement-card-title" style="margin: 0;"><?= htmlspecialchars(lang('use_read_db')) ?></h3>
</label>
<input type="checkbox" name="use_read_db" id="use-read-db" value="1"
<?= !empty($formData['use_read_db']) ? 'checked' : '' ?>
<?= ! empty($formData['use_read_db']) ? 'checked' : '' ?>
class="toggle-switch">
</div>
<div id="read-db-section" class="requirement-card-body <?= empty($formData['use_read_db']) ? 'hidden' : '' ?>" style="transition: all 0.3s ease-out;">
@@ -220,11 +219,11 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<select name="admin_language" id="admin_language" class="form-select" required>
<?php
$selectedAdminLang = $formData['admin_language'] ?? getCurrentLanguage();
foreach (SUPPORTED_LANGUAGES as $code => $label): ?>
foreach (SUPPORTED_LANGUAGES as $code => $label) { ?>
<option value="<?= $code ?>" <?= $selectedAdminLang === $code ? 'selected' : '' ?>>
<?= htmlspecialchars($label) ?>
</option>
<?php endforeach; ?>
<?php } ?>
</select>
</div>
@@ -266,6 +265,21 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
</p>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('asset_url_mode')) ?></label>
<select name="asset_url_mode_select" id="asset_url_mode_select" class="form-select">
<option value="extension"><?= htmlspecialchars(lang('asset_url_mode_extension')) ?></option>
<option value="extensionless"><?= htmlspecialchars(lang('asset_url_mode_extensionless')) ?></option>
</select>
<p id="asset_url_mode_status" class="form-help hidden"
data-msg-extension="<?= htmlspecialchars(lang('asset_url_mode_detected_extension')) ?>"
data-msg-extensionless="<?= htmlspecialchars(lang('asset_url_mode_detected_extensionless')) ?>"
data-msg-unavailable="<?= htmlspecialchars(lang('asset_url_mode_detected_unavailable')) ?>"></p>
<p class="form-help">
<?= htmlspecialchars(lang('asset_url_mode_help')) ?>
</p>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('core_update_github_url')) ?></label>
<input type="url" name="core_update_github_url"
@@ -307,7 +321,7 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<h3 class="requirement-card-title" style="margin: 0;" id="php-cli-title"><?= htmlspecialchars(lang('php_cli_settings')) ?></h3>
</label>
<input type="checkbox" id="show-php-cli-settings" value="1"
<?= (!empty($formData['php_binary']) && $formData['php_binary'] !== 'php') || !empty($formData['composer_binary']) ? 'checked' : '' ?>
<?= (! empty($formData['php_binary']) && $formData['php_binary'] !== 'php') || ! empty($formData['composer_binary']) ? 'checked' : '' ?>
class="toggle-switch">
</div>
<div id="php-cli-section" class="requirement-card-body <?= (empty($formData['php_binary']) || $formData['php_binary'] === 'php') && empty($formData['composer_binary']) ? 'hidden' : '' ?>" style="transition: all 0.3s ease-out;">
@@ -384,14 +398,14 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<!-- ========== Vendor 설치 방식 ========== -->
<?php
$bundleZipExists = file_exists(BASE_PATH . '/vendor-bundle.zip');
$zipArchiveAvailable = class_exists('ZipArchive');
$procOpenAvailable = function_exists('proc_open')
&& !in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true);
$bundleAvailable = $bundleZipExists && $zipArchiveAvailable;
$composerAvailable = $procOpenAvailable;
$currentVendorMode = $formData['vendor_mode'] ?? 'auto';
?>
$bundleZipExists = file_exists(BASE_PATH.'/vendor-bundle.zip');
$zipArchiveAvailable = class_exists('ZipArchive');
$procOpenAvailable = function_exists('proc_open')
&& ! in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true);
$bundleAvailable = $bundleZipExists && $zipArchiveAvailable;
$composerAvailable = $procOpenAvailable;
$currentVendorMode = $formData['vendor_mode'] ?? 'auto';
?>
<div class="requirement-card">
<div class="requirement-card-header">
<h2 class="card-title"><?= htmlspecialchars(lang('vendor_mode_title')) ?></h2>
@@ -437,15 +451,15 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<p class="vendor-mode-card-description">
<?= htmlspecialchars(lang('vendor_mode_composer_description')) ?>
</p>
<?php if (!$composerAvailable): ?>
<?php if (! $composerAvailable) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_composer_status_blocked')) ?>
</p>
<?php else: ?>
<?php } else { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-ok">
<?= getSvgIcon('check') ?: '✓' ?> <?= htmlspecialchars(lang('vendor_mode_composer_status_ok')) ?>
</p>
<?php endif; ?>
<?php } ?>
</label>
<!-- 번들 Vendor 사용 -->
@@ -464,19 +478,19 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<p class="vendor-mode-card-description">
<?= htmlspecialchars(lang('vendor_mode_bundled_description')) ?>
</p>
<?php if (!$zipArchiveAvailable): ?>
<?php if (! $zipArchiveAvailable) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_no_ziparchive')) ?>
</p>
<?php elseif (!$bundleZipExists): ?>
<?php } elseif (! $bundleZipExists) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_no_zip')) ?>
</p>
<?php else: ?>
<?php } else { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-ok">
<?= getSvgIcon('check') ?: '✓' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_ok')) ?>
</p>
<?php endif; ?>
<?php } ?>
</label>
</div>
<p class="form-help" style="margin-top: var(--spacing-md);">
@@ -502,6 +516,12 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
</div>
<!-- 네비게이션 -->
<?php /* 자산 URL 방식 (이슈 #486) — 아래 스크립트가 브라우저에서 프로브를 던져 채운다.
서버측에서 자기 APP_URL 로 curl 하면 loopback 이 vhost·프록시 체인을 우회해
오판하므로 반드시 브라우저가 판정해야 한다. 판정 실패 시 빈 값으로 남고
설치는 defaults.json 기본값(extension)으로 진행된다. */ ?>
<input type="hidden" name="asset_url_mode" id="asset_url_mode" value="">
<div class="btn-group btn-group-spread">
<button type="button" onclick="goToStep(2)" class="btn btn-secondary">
<?= htmlspecialchars(lang('previous')) ?>
@@ -588,18 +608,18 @@ window.CliValidator = {
summary.style.display = '';
phpStatus.textContent = this.phpVerified
? '<?= lang("cli_status_verified") ?>'
: '<?= lang("cli_status_not_verified") ?>';
? '<?= lang('cli_status_verified') ?>'
: '<?= lang('cli_status_not_verified') ?>';
phpStatus.style.color = this.phpVerified ? 'var(--success-color)' : 'var(--error-color)';
const composerOptional = !this.isComposerRequired();
if (composerOptional) {
composerStatus.textContent = '<?= lang("cli_status_optional_bundled") ?>';
composerStatus.textContent = '<?= lang('cli_status_optional_bundled') ?>';
composerStatus.style.color = 'var(--text-muted-color, #888)';
} else {
composerStatus.textContent = this.composerVerified
? '<?= lang("cli_status_verified") ?>'
: '<?= lang("cli_status_not_verified") ?>';
? '<?= lang('cli_status_verified') ?>'
: '<?= lang('cli_status_not_verified') ?>';
composerStatus.style.color = this.composerVerified ? 'var(--success-color)' : 'var(--error-color)';
}
},
@@ -663,8 +683,8 @@ async function initCliDetection() {
const summary = document.getElementById('cli-status-summary');
if (summary) summary.style.display = '';
if (phpStatus) { phpStatus.textContent = '<?= lang("cli_status_checking") ?>'; phpStatus.style.color = ''; }
if (composerStatus) { composerStatus.textContent = '<?= lang("cli_status_checking") ?>'; composerStatus.style.color = ''; }
if (phpStatus) { phpStatus.textContent = '<?= lang('cli_status_checking') ?>'; phpStatus.style.color = ''; }
if (composerStatus) { composerStatus.textContent = '<?= lang('cli_status_checking') ?>'; composerStatus.style.color = ''; }
// PHP 감지 먼저 실행 (composer 감지 결과 포함) → Composer 검증
const composerData = await detectAndVerifyPhp();
@@ -736,10 +756,10 @@ function switchToRequiredMode() {
window.CliValidator.cliRequired = true;
const title = document.getElementById('php-cli-title');
if (title) title.textContent = '<?= lang("php_cli_settings_required") ?>';
if (title) title.textContent = '<?= lang('php_cli_settings_required') ?>';
const helpText = document.getElementById('php-cli-help-text');
if (helpText) helpText.textContent = '<?= lang("php_cli_settings_help_required") ?>';
if (helpText) helpText.textContent = '<?= lang('php_cli_settings_help_required') ?>';
// 섹션 강제 오픈 + 토글 비활성화
const section = document.getElementById('php-cli-section');
@@ -760,7 +780,7 @@ async function testPhpBinary() {
const resultDiv = document.getElementById('php-binary-test-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang("checking") ?>';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=test-php-binary', {
@@ -781,7 +801,7 @@ async function testPhpBinary() {
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang("error_check_failed") ?>';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
window.CliValidator.setPhpVerified(false);
}
}
@@ -793,7 +813,7 @@ async function testComposer() {
const resultDiv = document.getElementById('composer-test-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang("composer_checking") ?>';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('composer_checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=test-composer', {
@@ -818,7 +838,7 @@ async function testComposer() {
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang("error_check_failed") ?>';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
window.CliValidator.setComposerVerified(false);
showComposerInstallGuide();
}
@@ -847,40 +867,40 @@ function showComposerInstallGuide() {
let html = '';
// 표준 설치 안내 (항상 표시)
html += '<p class="fix-guide-label"><?= lang("composer_install_guide_global") ?></p>';
html += '<p class="fix-guide-label"><?= lang('composer_install_guide_global') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang("copy_command") ?></button>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-sm);"><?= lang("composer_install_guide_local") ?></p>';
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_local') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -sS https://getcomposer.org/installer | php</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang("copy_command") ?></button>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
// 호스팅 환경 안내 (항상 표시 — 표준 방법 실패 시 대안)
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-md);"><?= lang("composer_install_guide_hosting") ?></p>';
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-md);"><?= lang('composer_install_guide_hosting') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -o composer-setup.php https://getcomposer.org/installer</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang("copy_command") ?></button>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<div class="code-box" style="margin-top: var(--spacing-xs);">';
html += '<pre class="fix-command">' + escapeHtml(phpPath) + ' -d allow_url_fopen=On composer-setup.php</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang("copy_command") ?></button>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang("composer_install_guide_pwd_hint") ?></p>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_pwd_hint') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">pwd</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang("copy_command") ?></button>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang("composer_install_guide_phar_hint") ?></p>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_phar_hint') ?></p>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang("composer_install_guide_link") ?></p>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_link') ?></p>';
commandsDiv.innerHTML = html;
guide.style.display = '';
@@ -903,7 +923,7 @@ function showDetectResult(data) {
if (!resultDiv) return;
if (data.success && data.binaries && data.binaries.length > 0) {
let html = '<strong>' + '<?= lang("detected_php_binaries") ?>' + '</strong><ul style="margin: var(--spacing-xs) 0; padding-left: var(--spacing-lg);">';
let html = '<strong>' + '<?= lang('detected_php_binaries') ?>' + '</strong><ul style="margin: var(--spacing-xs) 0; padding-left: var(--spacing-lg);">';
data.binaries.forEach(function(bin) {
html += '<li><a href="#" onclick="selectPhpBinary(\'' + bin.path.replace(/'/g, "\\'") + '\'); return false;" style="cursor: pointer;">'
+ bin.path + '</a> — PHP ' + bin.version + '</li>';
@@ -913,7 +933,7 @@ function showDetectResult(data) {
resultDiv.innerHTML = html;
} else {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = data.message || '<?= lang("no_php_detected") ?>';
resultDiv.innerHTML = data.message || '<?= lang('no_php_detected') ?>';
}
}
@@ -935,7 +955,7 @@ async function detectPhpBinaries() {
const resultDiv = document.getElementById('php-detect-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang("detecting_php") ?>';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('detecting_php') ?>';
try {
const response = await fetch('api/check-configuration.php?action=detect-php');
@@ -947,7 +967,7 @@ async function detectPhpBinaries() {
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang("error_check_failed") ?>';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
}
}
@@ -958,7 +978,7 @@ function copyCliCommand(btn) {
const command = btn.previousElementSibling.textContent;
navigator.clipboard.writeText(command).then(function() {
const originalText = btn.textContent;
btn.textContent = '<?= lang("copied") ?>';
btn.textContent = '<?= lang('copied') ?>';
setTimeout(function() { btn.textContent = originalText; }, 2000);
});
}
@@ -990,7 +1010,7 @@ async function checkCorePendingPath() {
if (!path) return;
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang("checking") ?>';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=check-core-pending-path&path=' + encodeURIComponent(path));
@@ -1005,7 +1025,7 @@ async function checkCorePendingPath() {
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang("error_check_failed") ?>';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
}
}
</script>
+5 -4
View File
@@ -29,6 +29,7 @@ import { webSocketManager } from './websocket/WebSocketManager';
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
import { SystemBannerManager } from './template-engine/SystemBannerManager';
import { fetchWithRetry, installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience';
import { suffixed } from './support/assetUrl';
import { resetLocalInitTracking } from './template-engine/localInitSlot';
/**
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
@@ -472,7 +473,7 @@ export class TemplateApp {
// routes.json 로딩 (저장된 캐시 버전 사용)
// 네트워크 일시 실패(응답 없음)에만 재시도한다. HTTP 에러는 아래 체인이 종전대로 throw.
fetchWithRetry(
`/api/templates/${this.config.templateId}/routes.json${storedCacheVersion > 0 ? `?v=${storedCacheVersion}` : ''}`,
suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', storedCacheVersion > 0 ? storedCacheVersion : null),
{ label: 'routes.json' }
)
.then(response => {
@@ -494,7 +495,7 @@ export class TemplateApp {
// 사용자 정보 프리로드 (에러 발생 시 무시)
authManager.preloadAuth(this.config.templateType === 'admin' ? 'admin' : 'user'),
// 템플릿 config.json 로딩 (errorHandling 파싱)
fetch(`/api/templates/${this.config.templateId}/config.json`)
fetch(suffixed(`/api/templates/${this.config.templateId}/config`, 'json'))
.then(response => {
if (!response.ok) {
// config.json 로드 실패는 무시 (선택적)
@@ -532,7 +533,7 @@ export class TemplateApp {
logger.log('Cache version changed, reloading routes...');
// routes.json을 새 캐시 버전으로 다시 로드
const newRoutesData = await fetchWithRetry(
`/api/templates/${this.config.templateId}/routes.json?v=${this.extensionCacheVersion}`,
suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', this.extensionCacheVersion),
{ label: 'routes.json (reload)' }
)
.then(response => {
@@ -2807,7 +2808,7 @@ export class TemplateApp {
let newVersion: number | undefined;
try {
const configResponse = await fetch(
`/api/templates/${this.config.templateId}/config.json?_=${Date.now()}`
suffixed(`/api/templates/${this.config.templateId}/config`, 'json', null, `_=${Date.now()}`)
);
if (configResponse.ok) {
const configResult = await configResponse.json();
+15 -5
View File
@@ -8,6 +8,7 @@
import { createLogger } from '../utils/Logger';
import { loadScriptWithRetry } from '../template-engine/networkResilience';
import { convertToCurrentMode } from '../support/assetUrl';
const logger = createLogger('ModuleAssetLoader');
@@ -452,8 +453,8 @@ export function parseModuleAssetsFromConfig(): ModuleAsset[] {
moduleAssets.push({
identifier,
js: typedAsset.js,
css: typedAsset.css,
js: typedAsset.js ? convertToCurrentMode(typedAsset.js) : typedAsset.js,
css: typedAsset.css ? convertToCurrentMode(typedAsset.css) : typedAsset.css,
priority: typedAsset.priority,
external: typedAsset.external,
});
@@ -495,7 +496,16 @@ export function parseBundleUrlsFromConfig(): ExtensionBundleUrls | null {
return null;
}
return g7Config.bundleUrls as ExtensionBundleUrls;
// 서버가 확장자 형태로 굳혀 내려준 URL 을 현재 모드로 변환한다.
// 부트스트랩 자가 복구가 런타임에 모드를 뒤집은 경우 원본은 이미 옛 형태다.
const urls = g7Config.bundleUrls as ExtensionBundleUrls;
return {
moduleJs: urls.moduleJs ? convertToCurrentMode(urls.moduleJs) : urls.moduleJs,
moduleCss: urls.moduleCss ? convertToCurrentMode(urls.moduleCss) : urls.moduleCss,
pluginJs: urls.pluginJs ? convertToCurrentMode(urls.pluginJs) : urls.pluginJs,
pluginCss: urls.pluginCss ? convertToCurrentMode(urls.pluginCss) : urls.pluginCss,
} as ExtensionBundleUrls;
}
/**
@@ -525,8 +535,8 @@ export function parsePluginAssetsFromConfig(): ModuleAsset[] {
pluginAssets.push({
identifier,
js: typedAsset.js,
css: typedAsset.css,
js: typedAsset.js ? convertToCurrentMode(typedAsset.js) : typedAsset.js,
css: typedAsset.css ? convertToCurrentMode(typedAsset.css) : typedAsset.css,
priority: typedAsset.priority,
external: typedAsset.external,
});
+5 -4
View File
@@ -1,5 +1,6 @@
import { AuthManager } from '../auth/AuthManager';
import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl';
const logger = createLogger('Router');
@@ -107,10 +108,10 @@ export class Router {
*/
async loadRoutes(cacheVersion?: number): Promise<void> {
try {
const versionQuery = cacheVersion !== undefined && cacheVersion > 0
? `?v=${cacheVersion}`
: '';
const response = await fetch(`/api/templates/${this.templateIdentifier}/routes.json${versionQuery}`);
const version = cacheVersion !== undefined && cacheVersion > 0 ? cacheVersion : null;
const response = await fetch(
suffixed(`/api/templates/${this.templateIdentifier}/routes`, 'json', version),
);
if (!response.ok) {
throw new Error(`Failed to load routes: ${response.statusText}`);
@@ -0,0 +1,223 @@
/**
* 자산 URL 빌더(프론트) 가드 — 이슈 #486 단위 C.
*
* 단위 C 의 회귀 조건은 "기본 모드에서 치환 이전 URL 문자열과 바이트 동일" 이다.
* 아래 확장자 모드 기대값은 치환 이전 소스에 하드코딩되어 있던 문자열을 그대로 옮긴 것으로,
* 빌더 도입이 URL 을 한 글자도 바꾸지 않았음을 고정한다.
*
* 서버측 `App\Support\AssetUrl` 와 규칙이 일치해야 한다 — 한쪽만 바뀌면 그 자산만 404 가 된다.
*/
// e2e:allow 순수 URL 문자열 생성 유닛. 브라우저 거동은 단위 D(자가 복구) spec 이 커버한다.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
MODE_EXTENSION,
MODE_EXTENSIONLESS,
getAssetUrlMode,
isExtensionless,
setAssetUrlMode,
restoreCachedMode,
templateAsset,
moduleAsset,
pluginAsset,
extensionBundle,
suffixed,
layoutUrl,
layoutPreviewUrl,
} from '../assetUrl';
/**
* G7Config 를 지정 모드로 초기화합니다.
*/
function setConfig(mode?: string, cacheVersion = 7): void {
// 자가 복구가 기록하는 독립 전역까지 초기화한다 — 남아 있으면 이후 케이스가
// 앞 케이스의 전환 결과를 물려받아 거짓 실패/거짓 통과가 난다.
delete (globalThis as any).__g7AssetUrlMode;
(globalThis as any).G7Config = {
cache_version: cacheVersion,
...(mode ? { assetUrlMode: mode } : {}),
};
}
describe('assetUrl (프론트 자산 URL 빌더)', () => {
beforeEach(() => {
globalThis.localStorage?.clear();
setConfig(MODE_EXTENSION);
});
afterEach(() => {
delete (globalThis as any).G7Config;
delete (globalThis as any).__g7AssetUrlMode;
globalThis.localStorage?.clear();
});
describe('모드 판정', () => {
it('G7Config 부재 시 기본 모드는 확장자 유지', () => {
delete (globalThis as any).G7Config;
expect(getAssetUrlMode()).toBe(MODE_EXTENSION);
expect(isExtensionless()).toBe(false);
});
it('런타임 키가 설정값보다 우선한다', () => {
(globalThis as any).G7Config = {
assetUrlMode: MODE_EXTENSIONLESS,
settings: { general: { asset_url_mode: MODE_EXTENSION } },
};
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('런타임 키가 없으면 설정값을 따른다', () => {
(globalThis as any).G7Config = {
settings: { general: { asset_url_mode: MODE_EXTENSIONLESS } },
};
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('알 수 없는 값은 기본 모드로 폴백한다', () => {
(globalThis as any).G7Config = { assetUrlMode: 'nonsense' };
expect(getAssetUrlMode()).toBe(MODE_EXTENSION);
});
});
describe('확장자 모드 — 치환 이전 문자열과 바이트 동일', () => {
it('Router.ts:113 routes.json (버전 있음/없음)', () => {
expect(suffixed('/api/templates/sirsoft-basic/routes', 'json', 7)).toBe(
'/api/templates/sirsoft-basic/routes.json?v=7',
);
expect(suffixed('/api/templates/sirsoft-basic/routes', 'json', null)).toBe(
'/api/templates/sirsoft-basic/routes.json',
);
});
it('ComponentRegistry.ts:250 components.json', () => {
expect(suffixed('/api/templates/sirsoft-basic/components', 'json')).toBe(
'/api/templates/sirsoft-basic/components.json',
);
});
it('TemplateApp.ts:2810 config.json + 캐시버스트 쿼리', () => {
expect(suffixed('/api/templates/sirsoft-basic/config', 'json', null, '_=1699999999')).toBe(
'/api/templates/sirsoft-basic/config.json?_=1699999999',
);
});
it('LayoutLoader.ts:750,752 레이아웃 / 미리보기', () => {
expect(layoutUrl('sirsoft-basic', 'home', 7)).toBe('/api/layouts/sirsoft-basic/home.json?v=7');
expect(layoutPreviewUrl('abc-def')).toBe('/api/layouts/preview/abc-def.json');
});
it('편집기 문서 로드 — with_source_meta 가 v 보다 앞 (캐시 키 보존)', () => {
expect(
suffixed('/api/layouts/sirsoft-basic/home', 'json', '7.3', 'with_source_meta=1'),
).toBe('/api/layouts/sirsoft-basic/home.json?with_source_meta=1&v=7.3');
});
it('자산 URL — 템플릿/모듈/플러그인', () => {
expect(templateAsset('sirsoft-basic', 'js/components.iife.js', 7)).toBe(
'/api/templates/assets/sirsoft-basic/js/components.iife.js?v=7',
);
expect(moduleAsset('sirsoft-ecommerce', 'dist/js/module.iife.js', 7)).toBe(
'/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js?v=7',
);
expect(pluginAsset('sirsoft-gdpr', 'dist/css/plugin.css')).toBe(
'/api/plugins/assets/sirsoft-gdpr/dist/css/plugin.css',
);
});
it('병합 번들', () => {
expect(extensionBundle('modules', 'js', 7)).toBe('/api/modules/bundle.js?v=7');
expect(extensionBundle('plugins', 'css', 7)).toBe('/api/plugins/bundle.css?v=7');
});
});
describe('확장자 없는 모드', () => {
beforeEach(() => setConfig(MODE_EXTENSIONLESS));
it('고정 접미사를 제거한다', () => {
expect(suffixed('/api/templates/sirsoft-basic/routes', 'json', 7)).toBe(
'/api/templates/sirsoft-basic/routes?v=7',
);
expect(layoutUrl('sirsoft-basic', 'home', 7)).toBe('/api/layouts/sirsoft-basic/home?v=7');
});
it('자산 경로를 file 쿼리로 옮긴다', () => {
expect(templateAsset('sirsoft-basic', 'js/components.iife.js', 7)).toBe(
'/api/templates/assets/sirsoft-basic?file=js%2Fcomponents.iife.js&v=7',
);
});
it('번들 접미사를 경로 세그먼트로 내린다', () => {
expect(extensionBundle('modules', 'js', 7)).toBe('/api/modules/bundle/js?v=7');
expect(extensionBundle('plugins', 'css')).toBe('/api/plugins/bundle/css');
});
it('생성된 모든 URL 의 경로에 정적 확장자가 남지 않는다', () => {
const urls = [
suffixed('/api/templates/t/routes', 'json', 7),
layoutUrl('t', 'home', 7),
layoutPreviewUrl('abc'),
templateAsset('t', 'js/a.js', 7),
moduleAsset('m', 'dist/js/a.js'),
pluginAsset('p', 'dist/css/a.css'),
extensionBundle('modules', 'js', 7),
];
for (const url of urls) {
const path = url.split('?')[0];
expect(path, `경로에 정적 확장자 잔존: ${url}`).not.toMatch(/\.(js|mjs|css|json|map)$/i);
}
});
});
describe('런타임 전환 (§12 L1 — 단방향 1회)', () => {
it('extension → extensionless 전환은 허용된다', () => {
expect(setAssetUrlMode(MODE_EXTENSIONLESS)).toBe(true);
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('역방향 전환은 거부된다 (무한 왕복 차단)', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
expect(setAssetUrlMode(MODE_EXTENSION as any)).toBe(false);
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('이미 전환된 상태에서 재전환은 false 를 반환한다 (1회 제한)', () => {
expect(setAssetUrlMode(MODE_EXTENSIONLESS)).toBe(true);
expect(setAssetUrlMode(MODE_EXTENSIONLESS)).toBe(false);
});
});
describe('localStorage 캐시 (§12 L7)', () => {
it('전환 결과가 캐시되어 재방문 시 복원된다', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
setConfig(MODE_EXTENSION);
expect(restoreCachedMode()).toBe(true);
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('cache_version 이 바뀌면 옛 캐시가 무시된다', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
setConfig(MODE_EXTENSION, 8);
expect(restoreCachedMode()).toBe(false);
expect(getAssetUrlMode()).toBe(MODE_EXTENSION);
});
it('TTL 을 넘긴 캐시는 무시되고 제거된다', () => {
const stale = JSON.stringify({ mode: MODE_EXTENSIONLESS, at: Date.now() - 25 * 60 * 60 * 1000 });
globalThis.localStorage.setItem('g7_asset_url_mode:7', stale);
setConfig(MODE_EXTENSION);
expect(restoreCachedMode()).toBe(false);
expect(globalThis.localStorage.getItem('g7_asset_url_mode:7')).toBeNull();
});
});
});
@@ -0,0 +1,301 @@
/**
* 자산 URL 자가 복구 불변식 가드 — 이슈 #486 단위 D (§12 L1~L9).
*
* 자가 복구는 "실패했으니 다시 시도한다" 는 구조라 루프 위험이 내재한다.
* 계획서 §12 는 이를 구현 불변식으로 못박았고, 각각에 대응 테스트를 요구한다.
* 여기서는 브라우저 없이 검증 가능한 불변식(L1·L5·L7)과, blade 인라인 복구기와
* TS 빌더의 **규칙 드리프트**를 다룬다. 요청 횟수·리로드·폴백 UI(L2·L3·L4·L8)는
* 브라우저 계층이므로 Playwright spec 이 담당한다.
*/
// e2e:allow 불변식의 순수 로직 단위. 브라우저 계층(L2·L3·L4·L8)은 asset-url-mode.spec.ts 가 커버한다.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import {
MODE_EXTENSION,
MODE_EXTENSIONLESS,
getAssetUrlMode,
setAssetUrlMode,
convertToCurrentMode,
} from '../assetUrl';
/**
* blade 파샬의 인라인 `toExtensionless` 를 추출해 실행 가능한 함수로 만든다.
*
* 부트스트랩은 코어 번들 로드 전에 동작해야 해서 import 를 쓸 수 없고, 그래서 변환 규칙이
* blade 인라인 JS 에 한 벌 더 존재한다. 두 구현이 갈라지면 서버가 만든 URL 과
* 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 되므로, 실제 blade 소스에서
* 함수를 뽑아 TS 구현과 대조한다.
*/
function loadInlineConverter(): (url: string) => string | null {
const bladePath = resolve(__dirname, '../../../../views/partials/asset-url-recovery.blade.php');
const source = readFileSync(bladePath, 'utf-8');
const start = source.indexOf('function toExtensionless(url) {');
expect(start, 'blade 파샬에서 toExtensionless 를 찾지 못했다').toBeGreaterThan(-1);
// 균형 중괄호로 함수 본문 끝을 찾는다
let depth = 0;
let end = start;
for (let i = source.indexOf('{', start); i < source.length; i += 1) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) {
end = i + 1;
break;
}
}
}
const fnSource = source.slice(start, end);
// 함수가 참조하는 파샬 상단 상수를 함께 주입한다 (blade 스코프 재현)
// eslint-disable-next-line no-new-func
return new Function(
`var FILE_QUERY_PARAM = 'file';\n${fnSource}\nreturn toExtensionless;`,
)() as (url: string) => string | null;
}
describe('자산 URL 자가 복구 불변식 (§12)', () => {
beforeEach(() => {
delete (globalThis as any).__g7AssetUrlMode;
(globalThis as any).G7Config = { cache_version: 7 };
globalThis.localStorage?.clear();
});
afterEach(() => {
delete (globalThis as any).__g7AssetUrlMode;
delete (globalThis as any).G7Config;
globalThis.localStorage?.clear();
});
describe('L1 — 전환은 단방향 1회', () => {
it('extension → extensionless 는 1회만 성공한다', () => {
expect(setAssetUrlMode(MODE_EXTENSIONLESS)).toBe(true);
expect(setAssetUrlMode(MODE_EXTENSIONLESS)).toBe(false);
});
it('역방향 전환 경로가 존재하지 않는다', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
expect(setAssetUrlMode(MODE_EXTENSION as any)).toBe(false);
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
it('G7Config 가 재대입되어도 전환 결과가 유지된다', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
// <body> 의 `window.G7Config = {...}` 재대입 모사
(globalThis as any).G7Config = { cache_version: 7, assetUrlMode: MODE_EXTENSION };
expect(getAssetUrlMode()).toBe(MODE_EXTENSIONLESS);
});
});
describe('L5 — 클라이언트는 서버 설정을 쓰지 않는다', () => {
it('전환이 settings 원본을 변경하지 않는다', () => {
(globalThis as any).G7Config = {
cache_version: 7,
settings: { general: { asset_url_mode: MODE_EXTENSION } },
};
setAssetUrlMode(MODE_EXTENSIONLESS);
expect((globalThis as any).G7Config.settings.general.asset_url_mode).toBe(MODE_EXTENSION);
});
});
describe('L7 — 캐시는 cache_version 을 포함하고 TTL 을 둔다', () => {
it('캐시 키에 cache_version 이 포함된다', () => {
setAssetUrlMode(MODE_EXTENSIONLESS);
expect(globalThis.localStorage.getItem('g7_asset_url_mode:7')).not.toBeNull();
});
});
describe('변환 규칙 — blade 인라인 복구기와 TS 빌더 동등성', () => {
const cases = [
'/api/templates/assets/sirsoft-basic/js/components.iife.js?v=7',
'/api/templates/assets/sirsoft-basic/css/components.css?v=7',
'/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js?v=7',
'/api/plugins/assets/sirsoft-gdpr/dist/css/plugin.css',
'/api/modules/bundle.js?v=7',
'/api/plugins/bundle.css',
'/api/templates/sirsoft-basic/routes.json?v=7',
'/api/layouts/sirsoft-basic/home.json?with_source_meta=1&v=7',
];
it('모든 대표 URL 에서 두 구현의 결과가 동일하다', () => {
const inline = loadInlineConverter();
(globalThis as any).__g7AssetUrlMode = MODE_EXTENSIONLESS;
for (const url of cases) {
expect(inline(url), `blade 인라인 변환기가 ${url} 을 변환하지 못했다`).not.toBeNull();
expect(inline(url), `변환 규칙 드리프트: ${url}`).toBe(convertToCurrentMode(url));
}
});
it('코어 엔진 번들은 두 구현 모두 변환하지 않는다', () => {
const inline = loadInlineConverter();
(globalThis as any).__g7AssetUrlMode = MODE_EXTENSIONLESS;
const coreBundle = '/build/core/template-engine.min.js?v=123';
// public/ 의 실물 정적 파일 — 변환하면 오히려 깨진다
expect(inline(coreBundle)).toBeNull();
expect(convertToCurrentMode(coreBundle)).toBe(coreBundle);
});
it('확장자 모드에서는 TS 빌더가 원본을 그대로 돌려준다', () => {
(globalThis as any).__g7AssetUrlMode = undefined;
(globalThis as any).G7Config = { cache_version: 7, assetUrlMode: MODE_EXTENSION };
for (const url of cases) {
expect(convertToCurrentMode(url)).toBe(url);
}
});
});
describe('L6 — 인스톨러 프로브도 본문 토큰 + Content-Type 으로 판정', () => {
/**
* 프로브 판정 로직은 두 곳에 존재한다 — 관리자 핸들러(`detectAssetUrlModeHandler`)와
* 인스톨러(`installer.js`). 전자는 동작 테스트로 red 증명까지 마쳤지만,
* 인스톨러는 코어 번들 이전에 도는 순수 브라우저 스크립트라 vitest 하네스가 없다.
*
* 하네스를 새로 만드는 대신 소스 수준 드리프트 가드를 둔다 — 두 검사 중 하나라도
* 사라지면 이 테스트가 red 가 된다. `200 + 에러 HTML` 을 반환하는 서버에서
* 설치가 영원히 `extension` 으로 오판되는 것을 막는 검사다.
*/
it('인스톨러 프로브가 두 검사를 모두 수행한다', () => {
const installerPath = resolve(
__dirname,
'../../../../../public/install/assets/js/installer.js',
);
const source = readFileSync(installerPath, 'utf-8');
const start = source.indexOf('async function detectAssetUrlMode(');
expect(start, 'installer.js 에서 detectAssetUrlMode 를 찾지 못했다').toBeGreaterThan(-1);
// 주석은 제거하고 실행 코드만 본다 — 주석에 남은 "Content-Type" 문구가
// 검사 삭제를 가려주면 가드가 무력해진다(실제로 겪은 오탐).
const body = source
.slice(start, start + 2500)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '');
expect(
/headers\s*\.\s*get\(\s*['"]content-type['"]/i.test(body),
'L6 위반: 인스톨러 프로브가 Content-Type 을 읽지 않는다 (200+HTML 오판)',
).toBe(true);
expect(
/javascript|ecmascript/i.test(body),
'L6 위반: 인스톨러 프로브에 스크립트 MIME 판정이 없다',
).toBe(true);
expect(
/includes\(\s*TOKEN\s*\)/.test(body),
'L6 위반: 인스톨러 프로브에 매직 토큰 검사가 없다 (무관한 200 응답 오판)',
).toBe(true);
});
});
describe('L9 — 감지 주체는 부트스트랩 하나', () => {
/**
* 엔진 레이어(Router/LayoutLoader/ComponentRegistry/TranslationEngine 등)는
* 확정된 모드를 **읽기만** 해야 한다. 계층마다 독립적으로 재감지하면
* 감지 캐스케이드가 중첩되어 요청이 폭증한다.
*
* 프로브 엔드포인트를 호출하는 코드가 엔진에 새로 들어오면 이 테스트가 잡는다.
*/
it('엔진 소스에 프로브 엔드포인트 호출이 없다', () => {
const engineRoot = resolve(__dirname, '../../');
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
walk(full);
continue;
}
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
const src = readFileSync(full, 'utf-8');
if (src.includes('asset-probe')) {
offenders.push(full.replace(engineRoot, ''));
}
}
};
walk(engineRoot);
expect(
offenders,
`엔진 레이어가 프로브를 직접 호출한다 (L9 위반 — 감지는 부트스트랩 단독):\n${offenders.join('\n')}`,
).toEqual([]);
});
});
describe('convertToCurrentMode 변환 결과', () => {
beforeEach(() => {
(globalThis as any).__g7AssetUrlMode = MODE_EXTENSIONLESS;
});
it('자산은 file 쿼리로, 번들은 세그먼트로, 접미사는 제거로', () => {
expect(convertToCurrentMode('/api/templates/assets/t/js/a.js?v=7')).toBe(
'/api/templates/assets/t?file=js%2Fa.js&v=7',
);
expect(convertToCurrentMode('/api/modules/bundle.js?v=7')).toBe('/api/modules/bundle/js?v=7');
expect(convertToCurrentMode('/api/templates/t/routes.json')).toBe('/api/templates/t/routes');
});
it('변환 결과 경로에 정적 확장자가 남지 않는다', () => {
for (const url of [
'/api/templates/assets/t/js/a.js?v=7',
'/api/modules/bundle.js',
'/api/templates/t/routes.json',
'/api/layouts/t/home.json?v=7',
]) {
const path = convertToCurrentMode(url).split('?')[0];
expect(path, `경로에 확장자 잔존: ${url}`).not.toMatch(/\.(js|css|json)$/i);
}
});
it('외부 origin URL 은 건드리지 않는다', () => {
const external = 'https://cdn.example.com/lib.js';
expect(convertToCurrentMode(external)).toBe(external);
});
// 회귀: `<script>.src` / `<link>.href` 는 절대 URL 을 돌려준다. same-origin
// 접두사를 벗기지 않으면 부트스트랩 재시도에서 변환이 항상 null 이 되어
// 자가 복구가 통째로 죽는다 — 외형은 "3회 재시도 후 폴백 UI" 라 원인이 안 보인다.
it('same-origin 절대 URL 도 변환한다 (DOM 프로퍼티 형태)', () => {
const origin = globalThis.location.origin;
expect(convertToCurrentMode(`${origin}/api/templates/assets/t/js/a.js?v=7`)).toBe(
'/api/templates/assets/t?file=js%2Fa.js&v=7',
);
expect(convertToCurrentMode(`${origin}/api/modules/bundle.js?v=7`)).toBe(
'/api/modules/bundle/js?v=7',
);
});
it('blade 인라인 복구기도 same-origin 절대 URL 을 변환한다', () => {
const inline = loadInlineConverter();
const origin = globalThis.location.origin;
expect(inline(`${origin}/api/templates/assets/t/js/a.js?v=7`)).toBe(
'/api/templates/assets/t?file=js%2Fa.js&v=7',
);
});
});
});
+436
View File
@@ -0,0 +1,436 @@
/**
* 자산·동적 엔드포인트 URL 생성기 (프론트측 SSoT).
*
* 서버측 `App\Support\AssetUrl` 와 **동일한 규칙**을 구현한다. 한쪽만 바뀌면
* 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 되므로,
* 규칙을 바꿀 때는 반드시 양쪽을 함께 수정한다.
*
* ## 배경
*
* nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기한다.
*
* ```nginx
* location ~* \.(js|css|json)$ { expires max; access_log off; }
* ```
*
* nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로
* `try_files ... /index.php` 폴백이 실행될 기회가 없다. 그런 환경에서는
* 확장자 붙은 동적 응답이 PHP 에 도달하지 못하고 404 가 된다.
*
* ## 모드
*
* | 모드 | 의미 |
* |---|---|
* | `extension` (기본) | 확장자 유지 — 정상 환경 |
* | `extensionless` | 확장자 제거 — 정적 블록이 가로채는 환경 |
*
* 초기값은 서버가 `window.G7Config.assetUrlMode` 로 내려준다. 부트스트랩
* 자가 복구가 확장자 형태 실패를 감지하면 런타임에 `extensionless` 로 전환한다
* (단방향 1회 — 역방향 전환은 만들지 않는다. 무한 왕복 방지).
*
* @since engine-v1.54.0
*/
/** 확장자 유지 모드 식별자. */
export const MODE_EXTENSION = 'extension';
/** 확장자 제거 모드 식별자. */
export const MODE_EXTENSIONLESS = 'extensionless';
/** 자산 URL 모드 타입. */
export type AssetUrlMode = typeof MODE_EXTENSION | typeof MODE_EXTENSIONLESS;
/** 확장자 없는 자산 URL 에서 파일 경로를 담는 쿼리 파라미터명. */
export const FILE_QUERY_PARAM = 'file';
/** localStorage 캐시 키 접두사 (cache_version 을 포함해 서버 정상화 후 고착 방지). */
const STORAGE_KEY_PREFIX = 'g7_asset_url_mode';
/** localStorage 캐시 TTL (24시간). 서버가 정상화되면 자동으로 재판정된다. */
const STORAGE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* 현재 자산 URL 모드를 반환합니다.
*
* 우선순위: 런타임 전환값(`G7Config.assetUrlMode`) → 설정값
* (`G7Config.settings.general.asset_url_mode`) → 기본값 `extension`.
*
* @returns 현재 모드
*/
export function getAssetUrlMode(): AssetUrlMode {
const config = (globalThis as any)?.G7Config;
// 부트스트랩 자가 복구가 기록하는 독립 전역이 최우선이다. `<head>` 파샬이 이 값을
// 확정한 뒤 `<body>` 에서 G7Config 객체가 통째로 재대입되는 순서라, G7Config 만
// 보면 전환 결과를 놓칠 수 있다.
const recovered = (globalThis as any)?.__g7AssetUrlMode;
if (recovered === MODE_EXTENSIONLESS) {
return MODE_EXTENSIONLESS;
}
const runtime = config?.assetUrlMode;
if (runtime === MODE_EXTENSIONLESS || runtime === MODE_EXTENSION) {
return runtime;
}
const configured = config?.settings?.general?.asset_url_mode;
if (configured === MODE_EXTENSIONLESS) {
return MODE_EXTENSIONLESS;
}
return MODE_EXTENSION;
}
/**
* 현재 모드가 확장자 없는 모드인지 여부를 반환합니다.
*
* @returns 확장자 없는 모드이면 true
*/
export function isExtensionless(): boolean {
return getAssetUrlMode() === MODE_EXTENSIONLESS;
}
/**
* 자산 URL 모드를 런타임에 전환합니다 (단방향).
*
* `extension → extensionless` 만 허용한다. 역방향을 허용하면 양쪽 형태가 모두
* 실패하는 상황(PHP 다운·WAF 차단)에서 `ext → extless → ext → …` 무한 왕복이 된다.
*
* 서버 설정은 바꾸지 않는다 — 미인증 클라이언트가 전역 설정을 뒤집을 수 있으면 안 된다.
*
* @param mode 전환할 모드
* @returns 실제로 전환되었으면 true
*/
export function setAssetUrlMode(mode: AssetUrlMode): boolean {
if (mode !== MODE_EXTENSIONLESS) {
return false;
}
if ((globalThis as any)?.__g7AssetUrlMode === MODE_EXTENSIONLESS) {
return false;
}
const config = (globalThis as any)?.G7Config;
if (config?.assetUrlMode === MODE_EXTENSIONLESS) {
return false;
}
(globalThis as any).__g7AssetUrlMode = MODE_EXTENSIONLESS;
if (config) {
config.assetUrlMode = MODE_EXTENSIONLESS;
}
persistMode(MODE_EXTENSIONLESS);
return true;
}
/**
* 캐시된 모드를 읽어 적용합니다 (같은 브라우저 재방문 시 첫 요청부터 정타).
*
* 캐시 키에 `cache_version` 을 포함하고 TTL 을 두어, 서버가 정상화된 뒤에도
* 클라이언트가 옛 모드에 영구 고착되지 않도록 한다.
*
* @returns 캐시가 적용되었으면 true
*/
export function restoreCachedMode(): boolean {
try {
const raw = globalThis.localStorage?.getItem(storageKey());
if (!raw) {
return false;
}
const parsed = JSON.parse(raw) as { mode?: string; at?: number };
if (parsed?.mode !== MODE_EXTENSIONLESS) {
return false;
}
if (typeof parsed.at !== 'number' || Date.now() - parsed.at > STORAGE_TTL_MS) {
globalThis.localStorage?.removeItem(storageKey());
return false;
}
(globalThis as any).__g7AssetUrlMode = MODE_EXTENSIONLESS;
const config = (globalThis as any)?.G7Config;
if (config) {
config.assetUrlMode = MODE_EXTENSIONLESS;
}
return true;
} catch {
// localStorage 접근 불가(사파리 프라이빗 등)는 치명적이지 않다 — 캐시 없이 진행
return false;
}
}
/**
* 템플릿 자산 URL 을 생성합니다.
*
* 템플릿은 서버가 `dist/` 를 자동 부가하므로 `path` 에 `dist/` 를 포함하지 않는다.
*
* @param identifier 템플릿 식별자
* @param path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
* @param version 캐시 무효화 버전 (없으면 미부착)
* @returns 생성된 URL
*/
export function templateAsset(identifier: string, path: string, version?: number | string | null): string {
return extensionAsset('templates', identifier, path, version);
}
/**
* 모듈 자산 URL 을 생성합니다.
*
* 모듈은 모듈 루트 기준이라 `path` 에 `dist/` 를 직접 포함한다 (템플릿과 비대칭).
*
* @param identifier 모듈 식별자
* @param path 모듈 루트 기준 파일 경로
* @param version 캐시 무효화 버전
* @returns 생성된 URL
*/
export function moduleAsset(identifier: string, path: string, version?: number | string | null): string {
return extensionAsset('modules', identifier, path, version);
}
/**
* 플러그인 자산 URL 을 생성합니다.
*
* @param identifier 플러그인 식별자
* @param path 플러그인 루트 기준 파일 경로
* @param version 캐시 무효화 버전
* @returns 생성된 URL
*/
export function pluginAsset(identifier: string, path: string, version?: number | string | null): string {
return extensionAsset('plugins', identifier, path, version);
}
/**
* 확장 타입을 인자로 받는 자산 URL 생성기.
*
* 확장자 없는 모드에서는 파일 경로를 `?file=` 쿼리로 옮긴다. 경로가 곧 파일명이라
* 접미사만 떼어낼 수 없기 때문이며, nginx 의 location 정규식이 쿼리스트링을 제외한
* 경로에만 매칭되므로 이 형태가 안전하다.
*
* @param type `templates` | `modules` | `plugins`
* @param identifier 확장 식별자
* @param path 파일 경로
* @param version 캐시 무효화 버전
* @returns 생성된 URL
*/
export function extensionAsset(
type: string,
identifier: string,
path: string,
version?: number | string | null,
): string {
const normalizedPath = path.replace(/^\/+/, '');
const id = encodeURIComponent(identifier);
if (!isExtensionless()) {
return `/api/${type}/assets/${id}/${normalizedPath}${versionQuery(version)}`;
}
let query = `${FILE_QUERY_PARAM}=${encodeURIComponent(normalizedPath)}`;
if (version !== undefined && version !== null && version !== '') {
query += `&v=${version}`;
}
return `/api/${type}/assets/${id}?${query}`;
}
/**
* 확장 병합 번들 URL 을 생성합니다.
*
* 접미사(js/css)가 번들 종류를 구분하므로 제거할 수 없다.
* 확장자 없는 모드에서는 경로 세그먼트로 내린다 (`bundle.js` → `bundle/js`).
*
* @param type `modules` | `plugins`
* @param kind `js` | `css`
* @param version 캐시 무효화 버전
* @returns 생성된 URL
*/
export function extensionBundle(type: string, kind: string, version?: number | string | null): string {
const base = isExtensionless() ? `/api/${type}/bundle/${kind}` : `/api/${type}/bundle.${kind}`;
return `${base}${versionQuery(version)}`;
}
/**
* 고정 접미사를 갖는 동적 엔드포인트 URL 을 생성합니다.
*
* 확장자 없는 모드에서는 접미사를 제거한다 (`routes.json` → `routes`).
* 추가 쿼리는 `extraQuery` 로 전달한다 (이미 `?` 가 붙은 뒤에 이어붙이지 않도록
* 결합을 이 함수가 책임진다).
*
* @param path 접미사를 제외한 경로 (예: `/api/templates/foo/routes`)
* @param suffix 접미사 (예: `json`)
* @param version 캐시 무효화 버전
* @param extraQuery 추가 쿼리스트링 (`a=1&b=2` 형태, `?` 없이)
* @returns 생성된 URL
*/
export function suffixed(
path: string,
suffix: string,
version?: number | string | null,
extraQuery?: string,
): string {
const base = path.replace(/\/+$/, '');
const normalized = suffix.replace(/^\.+/, '');
const url = isExtensionless() ? base : `${base}.${normalized}`;
// 순서 주의: extraQuery 를 v 보다 **앞**에 둔다. 치환 이전 호출부가
// `?with_source_meta=1&v=...` 순서로 만들고 있었고, 쿼리 순서가 바뀌면
// URL 문자열이 달라져 HTTP 캐시 키가 갈린다(의미는 같아도 캐시 미스).
const parts: string[] = [];
if (extraQuery) {
parts.push(extraQuery.replace(/^[?&]+/, ''));
}
if (version !== undefined && version !== null && version !== '') {
parts.push(`v=${version}`);
}
return parts.length > 0 ? `${url}?${parts.join('&')}` : url;
}
/**
* 레이아웃 서빙 URL 을 생성합니다.
*
* @param templateId 템플릿 식별자
* @param layoutPath 레이아웃 경로 (예: `home`, `board/list`)
* @param version 캐시 무효화 버전
* @param extraQuery 추가 쿼리스트링
* @returns 생성된 URL
*/
export function layoutUrl(
templateId: string,
layoutPath: string,
version?: number | string | null,
extraQuery?: string,
): string {
return suffixed(`/api/layouts/${encodeURIComponent(templateId)}/${layoutPath}`, 'json', version, extraQuery);
}
/**
* 레이아웃 미리보기 서빙 URL 을 생성합니다.
*
* @param token 미리보기 토큰 (UUID)
* @returns 생성된 URL
*/
export function layoutPreviewUrl(token: string): string {
return suffixed(`/api/layouts/preview/${encodeURIComponent(token)}`, 'json');
}
/**
* 서버가 확장자 형태로 만들어 내려준 URL 을 **현재 모드**에 맞게 변환합니다.
*
* `G7Config.bundleUrls` / `moduleAssets` / `pluginAssets` 는 서버 렌더 시점에
* 문자열로 굳어 내려온다. 부트스트랩 자가 복구가 런타임에 모드를 뒤집으면 이 값들이
* 옛 형태로 남으므로, 소비 지점에서 이 함수를 거쳐야 한다.
*
* 확장자 모드이거나 변환 대상이 아닌 URL(외부 origin, `/build/...` 정적 파일 등)은
* 원본을 그대로 돌려준다 — 코어 엔진 번들은 `public/` 의 실물 파일이라 변환하면 깨진다.
*
* @param url 서버가 생성한 URL
* @returns 현재 모드에 맞는 URL
*/
export function convertToCurrentMode(url: string): string {
if (!url || !isExtensionless()) {
return url;
}
// DOM 프로퍼티(`script.src` / `link.href`)는 절대 URL 을 돌려주므로
// same-origin 접두사를 먼저 벗긴다. 외부 origin 은 대상이 아니다.
const origin = (globalThis as any)?.location?.origin;
const relative = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
if (!relative.startsWith('/api/')) {
return url;
}
const [path, query] = splitQuery(relative);
// 1) 병합 번들: /api/{type}/bundle.{js|css} → /api/{type}/bundle/{js|css}
const bundleMatch = path.match(/^\/api\/(modules|plugins)\/bundle\.(js|css)$/);
if (bundleMatch) {
return joinQuery(`/api/${bundleMatch[1]}/bundle/${bundleMatch[2]}`, query);
}
// 2) 자산: /api/{type}/assets/{id}/{path} → /api/{type}/assets/{id}?file={path}
const assetMatch = path.match(/^\/api\/(templates|modules|plugins)\/assets\/([^/]+)\/(.+)$/);
if (assetMatch) {
const [, type, identifier, filePath] = assetMatch;
const fileQuery = `${FILE_QUERY_PARAM}=${encodeURIComponent(decodeURIComponent(filePath))}`;
return `/api/${type}/assets/${identifier}?${fileQuery}${query ? `&${query}` : ''}`;
}
// 3) 고정 접미사: /api/.../x.json → /api/.../x
const suffixMatch = path.match(/^(\/api\/.+)\.(json|js|css)$/);
if (suffixMatch) {
return joinQuery(suffixMatch[1], query);
}
return url;
}
/**
* URL 을 경로와 쿼리로 분리합니다.
*
* @param url 대상 URL
* @returns [경로, 쿼리(`?` 제외)]
*/
function splitQuery(url: string): [string, string] {
const idx = url.indexOf('?');
return idx === -1 ? [url, ''] : [url.slice(0, idx), url.slice(idx + 1)];
}
/**
* 경로와 쿼리를 결합합니다.
*
* @param path 경로
* @param query 쿼리 (`?` 제외)
* @returns 결합된 URL
*/
function joinQuery(path: string, query: string): string {
return query ? `${path}?${query}` : path;
}
/**
* 캐시 무효화 쿼리스트링을 생성합니다.
*
* @param version 버전 값
* @returns `?v=...` 또는 빈 문자열
*/
function versionQuery(version?: number | string | null): string {
if (version === undefined || version === null || version === '') {
return '';
}
return `?v=${version}`;
}
/**
* localStorage 캐시 키를 생성합니다 (cache_version 포함).
*
* @returns 캐시 키
*/
function storageKey(): string {
const cacheVersion = (globalThis as any)?.G7Config?.cache_version ?? 0;
return `${STORAGE_KEY_PREFIX}:${cacheVersion}`;
}
/**
* 판정된 모드를 localStorage 에 저장합니다.
*
* @param mode 저장할 모드
*/
function persistMode(mode: AssetUrlMode): void {
try {
globalThis.localStorage?.setItem(storageKey(), JSON.stringify({ mode, at: Date.now() }));
} catch {
// 저장 실패는 치명적이지 않다 — 다음 방문에 다시 판정하면 된다
}
}
@@ -5,6 +5,25 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.54.0] - 2026-07-20
### Added
#### 자산 URL 이중 모드 — 정적 최적화 서버에서의 동작 보장
- `support/assetUrl.ts`(신규) — 동적 엔드포인트 URL 생성을 한 곳으로 모았다. `getAssetUrlMode()` 가 `window.G7Config.assetUrlMode`(서버가 내려주는 초기값) → `G7Config.settings.general.asset_url_mode` → 기본값 `extension` 순으로 판정하고, `suffixed()` / `templateAsset()` / `moduleAsset()` / `pluginAsset()` / `extensionBundle()` / `layoutUrl()` / `layoutPreviewUrl()` 가 그 모드에 맞는 URL 을 만든다. 서버측 `App\Support\AssetUrl` 와 **동일 규칙**이며, 한쪽만 바꾸면 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 된다.
- 배경: nginx 의 정규식 location(`location ~* \.(js|css|json)$`)은 프리픽스 location 보다 먼저 매칭되므로, 확장자 붙은 동적 엔드포인트는 `try_files ... /index.php` 폴백이 실행될 기회 없이 nginx 가 직접 파일시스템을 열려 시도해 404 가 된다. aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있는 블록이다.
- 확장자 없는 모드의 변환 규칙 — 고정 접미사는 제거(`routes.json` → `routes`), 번들은 접미사가 종류를 구분하므로 세그먼트로 강등(`bundle.js` → `bundle/js`), 와일드카드 자산은 경로가 곧 파일명이라 쿼리로 이동(`assets/{id}/js/a.js` → `assets/{id}?file=js/a.js`). 마지막 형태가 안전한 이유는 nginx 의 location 정규식이 쿼리스트링을 제외한 경로에만 매칭되기 때문이다.
- `setAssetUrlMode()` 는 **단방향 1회**만 허용한다(`extension → extensionless`). 역방향을 허용하면 양쪽 형태가 모두 실패하는 상황(PHP 다운·WAF 차단)에서 무한 왕복이 된다. 서버 설정은 바꾸지 않는다 — 미인증 클라이언트가 전역 설정을 뒤집을 수 있으면 안 된다.
- `restoreCachedMode()` 의 localStorage 캐시는 키에 `cache_version` 을 포함하고 24시간 TTL 을 둔다. 서버가 정상화된 뒤에도 클라이언트가 옛 모드에 영구 고착되지 않도록 하기 위함이다.
### Changed
#### 동적 엔드포인트 URL 생성부 14지점을 빌더 경유로 전환
- `routing/Router.ts`, `TemplateApp.ts`(4), `ComponentRegistry.ts`, `ErrorPageHandler.ts`, `LayoutLoader.ts`(2), `layout-editor/LayoutEditorChrome.tsx`, `layout-editor/hooks/{useEditorTemplateAssets,useInlineEdit,useLayoutDocument,useExtensionDocument}.ts` — `routes.json`·`config.json`·`components.json`·레이아웃 JSON URL 을 모두 `suffixed()` 로 생성한다.
- 기본 모드(`extension`)에서 생성 결과는 치환 이전과 **문자열까지 동일**하다. `suffixed()` 는 추가 쿼리를 `v` 보다 앞에 놓는데(`?with_source_meta=1&v=...`), 이는 편집기 문서 로드 호출부의 기존 순서를 보존하기 위한 것이다 — 쿼리 순서가 바뀌면 의미는 같아도 URL 문자열이 달라져 HTTP 캐시 키가 갈린다.
## [engine-v1.53.1] - 2026-07-12
### Fixed
@@ -13,6 +13,7 @@
import React, { type ComponentType } from 'react';
import { createLogger } from '../utils/Logger';
import { fetchWithRetry } from './networkResilience';
import { suffixed } from '../support/assetUrl';
const logger = createLogger('ComponentRegistry');
@@ -247,7 +248,7 @@ export class ComponentRegistry {
// 캐시 미스 - API에서 로드
// 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리.
// @since engine-v1.53.0
const manifestUrl = `/api/templates/${this.templateId}/components.json`;
const manifestUrl = suffixed(`/api/templates/${this.templateId}/components`, 'json');
const response = await fetchWithRetry(manifestUrl, { label: 'components.json' });
if (!response.ok) {
@@ -8,6 +8,7 @@
import type { LayoutLoader } from './LayoutLoader';
import type { DataSourceManager, DataSource } from './DataSourceManager';
import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl';
const logger = createLogger('ErrorPageHandler');
@@ -83,7 +84,7 @@ export class ErrorPageHandler {
try {
// template.json fetch
const response = await fetch(`/api/templates/${this.templateId}/config.json`);
const response = await fetch(suffixed(`/api/templates/${this.templateId}/config`, 'json'));
if (!response.ok) {
if (this.debug) {
@@ -17,6 +17,7 @@ import type { ComponentRegistry } from './ComponentRegistry';
import type { ErrorHandlingMap } from '../types/ErrorHandling';
import { createLogger } from '../utils/Logger';
import { fetchWithRetry } from './networkResilience';
import { suffixed } from '../support/assetUrl';
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
import { getApiClient } from '../api/ApiClient';
@@ -744,14 +745,14 @@ export class LayoutLoader {
try {
// API 엔드포인트 구성 (캐시 버전 쿼리 파라미터 추가)
// 시스템 레이아웃 분기: __preview__ 는 별도 API 엔드포인트 사용
let baseUrl: string;
const version = this.cacheVersion > 0 ? this.cacheVersion : null;
let apiUrl: string;
if (layoutPath.startsWith('__preview__/')) {
const token = layoutPath.replace('__preview__/', '');
baseUrl = `/api/layouts/preview/${token}.json`;
apiUrl = suffixed(`/api/layouts/preview/${token}`, 'json', version);
} else {
baseUrl = `/api/layouts/${templateId}/${layoutPath}.json`;
apiUrl = suffixed(`/api/layouts/${templateId}/${layoutPath}`, 'json', version);
}
const apiUrl = this.cacheVersion > 0 ? `${baseUrl}?v=${this.cacheVersion}` : baseUrl;
logger.log('Fetching layout from API:', apiUrl);
@@ -13,6 +13,7 @@
*/
import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl';
const logger = createLogger('TranslationEngine');
@@ -221,20 +222,22 @@ export class TranslationEngine {
try {
// API 호출 (캐시 버전 쿼리 파라미터 추가, bustCache가 true면 타임스탬프도 추가)
let url = `${apiBaseUrl}/templates/${templateId}/lang/${locale}.json`;
// 쿼리 파라미터 구성
const queryParams: string[] = [];
if (this.cacheVersion > 0) {
queryParams.push(`v=${this.cacheVersion}`);
}
// 자산 URL 모드에 따라 `.json` 접미사가 붙거나 빠진다.
// 이 경로는 편집기 전용이 아니라 **모든 페이지가 타는 런타임 공통 경로**라,
// 여기만 확장자를 직접 조립하면 extensionless 환경에서 다국어가 통째로 404 가 되어
// 이슈 #486 의 원래 증상(화면이 온전히 뜨지 않음)이 다국어 계층에서 재현된다.
const extraParams: string[] = [];
if (bustCache) {
queryParams.push(`_=${Date.now()}`);
}
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`;
extraParams.push(`_=${Date.now()}`);
}
const url = suffixed(
`${apiBaseUrl}/templates/${templateId}/lang/${locale}`,
'json',
this.cacheVersion > 0 ? this.cacheVersion : null,
extraParams.length > 0 ? extraParams.join('&') : undefined,
);
const response = await fetch(url);
if (!response.ok) {
@@ -75,6 +75,7 @@ import { buildCoreActionRecipeSeed } from './spec/coreActionRecipes';
import { registerCoreWidgets } from './spec/registerCoreWidgets';
import { registerCoreEditors } from './spec/registerCoreEditors';
import { exposeLayoutEditorGlobals } from './spec/exposeLayoutEditorGlobals';
import { suffixed } from '../../support/assetUrl';
/**
* 편집기 셸 최소 너비(px) — 이 아래로는 셸을 압착하지 않고 브라우저 가로 스크롤로 흡수한다.
@@ -119,7 +120,7 @@ async function fetchPermissionCandidates(
if (typeof fetch !== 'function') return [];
try {
const response = await fetch(
`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/permission-candidates.json`,
suffixed(`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/permission-candidates`, 'json'),
{ credentials: 'same-origin', headers: buildAuthHeaders() },
);
if (!response.ok) return [];
@@ -223,7 +224,7 @@ function LayoutEditorChromeBody({
(async () => {
try {
const response = await fetch(
`/api/templates/${encodeURIComponent(templateIdentifier)}/components.json`,
suffixed(`/api/templates/${encodeURIComponent(templateIdentifier)}/components`, 'json'),
{ credentials: 'same-origin', headers: { Accept: 'application/json' } }
);
if (!response.ok) return;
@@ -1,3 +1,8 @@
// e2e:allow 이슈 #486 단위 A 는 서버 라우트 이중화 전용. 이 파일 변경은 편집기 CSS
// 엔드포인트 개명(editor/components.css → component-styles.css)을 따라간 기대값 수정뿐이며
// 브라우저 거동은 불변(URL 은 서버가 생성해 내려주고 hook 은 그대로 fetch).
// 자산 URL 모드의 브라우저 시나리오 spec 은 자가 복구를 도입하는 단위 D 에서 추가한다.
/**
* useEditorTemplateAssets 회귀 테스트
*
@@ -6,6 +11,7 @@
* hook 의 동작을 가드.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useEditorTemplateAssets } from '../../hooks/useEditorTemplateAssets';
@@ -118,7 +124,7 @@ describe('useEditorTemplateAssets', () => {
// 못 실어 500(Route[login])으로 떨어지므로, Bearer fetch → `<style>` 로 주입해야 한다.
it('권한 가드 admin CSS 는 Bearer fetch → <style> 주입 (link 아님)', async () => {
window.localStorage.setItem('auth_token', 'tok-css');
const adminCssUrl = '/api/admin/templates/sirsoft-basic/editor/components.css?v=1';
const adminCssUrl = '/api/admin/templates/sirsoft-basic/editor/component-styles.css?v=1';
fetchSpy.mockImplementation(async (url: string) => {
if (url.includes('/editor-assets')) {
return {
@@ -128,7 +134,7 @@ describe('useEditorTemplateAssets', () => {
}),
};
}
if (url.includes('/editor/components.css')) {
if (url.includes('/editor/component-styles.css')) {
return { ok: true, text: async () => '.g7le-preview-dark .x{color:red}' };
}
if (url.includes('/components.json')) {
@@ -150,7 +156,7 @@ describe('useEditorTemplateAssets', () => {
expect(styleEl?.textContent).toContain('.g7le-preview-dark');
// CSS fetch 에 Bearer 헤더 부착
const cssCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes('/editor/components.css'));
const cssCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes('/editor/component-styles.css'));
expect(cssCall?.[1]?.headers?.Authorization).toBe('Bearer tok-css');
});
@@ -59,6 +59,7 @@ import { ComputedForm } from './ComputedForm';
import { InitialStateForm } from './InitialStateForm';
import { ErrorHandlingForm } from './ErrorHandlingForm';
import { DataSourceTab } from './DataSourceTab';
import { suffixed } from '../../../../support/assetUrl';
/** 8탭 키 — 헤더 와이어프레임 순서 */
export type PageSettingsTabKey =
@@ -124,7 +125,7 @@ async function fetchSeoExtensions(templateIdentifier: string): Promise<SeoExtens
if (typeof fetch !== 'function' || !templateIdentifier) return [];
try {
const response = await fetch(
`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/seo-candidates.json`,
suffixed(`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/seo-candidates`, 'json'),
{ credentials: 'same-origin', headers: buildAuthHeaders() },
);
if (!response.ok) return [];
@@ -31,6 +31,7 @@ import { SeoBotPreviewPanel } from './SeoBotPreviewPanel';
import type { BindingCandidate } from '../../spec/bindingCandidates';
import type { DataSourceOption } from '../../spec/candidatePools';
import { DataSourceChipLabel } from './DataSourceChipLabel';
import { suffixed } from '../../../../support/assetUrl';
/** seo.extensions 칩 — `{type,id}` */
export interface SeoExtensionRef {
@@ -171,7 +172,7 @@ export function SeoForm({
}
const qs = params.toString() ? `?${params.toString()}` : '';
const res = await fetch(
`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/seo-candidates.json${qs}`,
suffixed(`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/seo-candidates`, 'json', null, qs.replace(/^[?&]+/, '')),
{ credentials: 'same-origin', headers: buildAuthHeaders() },
);
const body = await res.json().catch(() => null);
@@ -30,6 +30,7 @@ import {
type EditorAccessError,
} from '../types/editorErrors';
import { buildAuthHeaders } from '../utils/authToken';
import { suffixed } from '../../../support/assetUrl';
const logger = createLogger('useEditorRoutes');
@@ -246,7 +247,7 @@ export function useEditorRoutes(options: UseEditorRoutesOptions): void {
// 먼저 실행되는 진입 fetch 이므로, 이를 가드 엔드포인트 + Bearer 토큰으로 전환하면
// 미인증/권한부족이 진입 시점에 즉시 401/403 으로 감지되어 chrome(트리/캔버스)이
// 렌더되기 전 AccessErrorPanel → 로그인 리다이렉트로 분기된다.
const url = `/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/routes.json${versionQuery}`;
const url = suffixed(`/api/admin/templates/${encodeURIComponent(templateIdentifier)}/editor/routes`, 'json', null, versionQuery.replace(/^[?&]+/, ''));
// 편집 대상 템플릿의 lang dictionary 도 함께 적재 — 라우트 meta.title 에 있는
// `$t:user.*` 키가 부팅 템플릿(admin) lang 에는 없으므로 별도 로드 필요.
@@ -31,6 +31,7 @@ import {
type EditorAccessError,
} from '../types/editorErrors';
import { reseedPendingIntoEngine } from './pendingCustomTranslations';
import { suffixed } from '../../../support/assetUrl';
export interface EditorTemplateAssetsState {
componentRegistry: ComponentRegistry | null;
@@ -98,7 +99,7 @@ async function fetchLatestCacheVersion(identifier: string): Promise<number> {
try {
if (typeof fetch !== 'function') return 0;
const res = await fetch(
`/api/templates/${encodeURIComponent(identifier)}/config.json`,
suffixed(`/api/templates/${encodeURIComponent(identifier)}/config`, 'json'),
{ credentials: 'same-origin', headers: { Accept: 'application/json' } },
);
if (!res.ok) return 0;
@@ -46,6 +46,7 @@ import { trackEditorDocument } from '../devtools/editorTrackers';
import { readSanctumToken } from '../utils/authToken';
import { getCacheBustNonce, bumpCacheBustNonce } from '../utils/editorCacheBust';
import type { SaveResult } from './useLayoutDocument';
import { suffixed } from '../../../support/assetUrl';
/**
* 확장 편집 모드의 가상 path(`__extension__/{extensionId}`)에서 extensionId 를 추출한다.
@@ -420,9 +421,7 @@ export function useExtensionDocument(): UseExtensionDocumentResult {
const cacheVersion = (window as any).G7Config?.cache_version ?? 0;
// 클라이언트 캐시-버스트 nonce 합성 — useLayoutDocument 와 공용 카운터.
// 버전 복원/저장 후 reload 시 HTTP 캐시 stale 호스트 응답을 우회한다(확장 캔버스 미갱신 결함).
const url = `/api/layouts/${encodeURIComponent(
templateIdentifier,
)}/${hostLayoutName}.json?with_source_meta=1&v=${cacheVersion}.${getCacheBustNonce()}`;
const url = suffixed(`/api/layouts/${encodeURIComponent(templateIdentifier)}/${hostLayoutName}`, 'json', `${cacheVersion}.${getCacheBustNonce()}`, 'with_source_meta=1');
const token = readSanctumToken();
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
@@ -35,6 +35,7 @@ import {
} from '../devtools/editorTrackers';
import { setPendingValue, setPendingValues } from './pendingCustomTranslations';
import { TranslationEngine } from '../../TranslationEngine';
import { suffixed } from '../../../support/assetUrl';
import {
stripBindingTokens,
buildParamizedKeyText,
@@ -739,7 +740,7 @@ export async function bustTranslationCache(
// 최신 cache_version 을 노출하므로 그것을 읽어 엔진 버전을 갱신한 뒤 재로드한다.
try {
const cfg = await fetch(
`/api/templates/${encodeURIComponent(templateIdentifier)}/config.json`,
suffixed(`/api/templates/${encodeURIComponent(templateIdentifier)}/config`, 'json'),
{ headers: { Accept: 'application/json' }, credentials: 'same-origin' },
).then((r) => (r.ok ? r.json() : null));
const v = cfg?.data?.cache_version ?? cfg?.cache_version;
@@ -32,6 +32,7 @@ import { parseEditorPath } from './useElementSelection';
import { readSanctumToken } from '../utils/authToken';
import { getCacheBustNonce, bumpCacheBustNonce } from '../utils/editorCacheBust';
import { hasPending, flushPending } from './pendingCustomTranslations';
import { suffixed } from '../../../support/assetUrl';
/**
* 로드된 레이아웃 문서 — 백엔드 응답의 data 부분 (병합 + 확장 + 메타 포함)
@@ -440,7 +441,7 @@ export function useLayoutDocument(): UseLayoutDocumentResult {
const cacheVersion = (window as any).G7Config?.cache_version ?? 0;
// 저장 후 클라이언트 캐시-버스트 nonce 합성 — 같은 세션 저장→재로드가
// 옛 cache_version 으로 stale 응답을 받지 않도록 단조 증가 nonce 를 함께 붙인다.
const url = `/api/layouts/${encodeURIComponent(templateIdentifier)}/${layoutName}.json?with_source_meta=1&v=${cacheVersion}.${getCacheBustNonce()}`;
const url = suffixed(`/api/layouts/${encodeURIComponent(templateIdentifier)}/${layoutName}`, 'json', `${cacheVersion}.${getCacheBustNonce()}`, 'with_source_meta=1');
const token = readSanctumToken();
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) {
+21 -4
View File
@@ -16,9 +16,16 @@
@include('partials.error-fallback-styles')
@endif
{{-- 자산 URL 자가 복구 헬퍼 — CSS <link> 의 onerror 보다 먼저 정의되어야 한다 --}}
@include('partials.asset-url-recovery')
<!-- 템플릿 컴포넌트 스타일 -->
@if(!empty($activeAdminTemplate))
<link rel="stylesheet" href="/api/templates/assets/{{ $activeAdminTemplate }}/css/components.css?v={{ $extensionCacheVersion }}">
{{-- onerror: 정적 최적화 서버에서 확장자 붙은 CSS 가 가로채였을 때
확장자 없는 형태로 1회 교체한다(무스타일 화면 방지). 링크당 1회. --}}
<link rel="stylesheet"
href="{{ \App\Support\AssetUrl::templateAsset($activeAdminTemplate, 'css/components.css', $extensionCacheVersion) }}"
onerror="window.__g7AssetUrl && window.__g7AssetUrl.recoverStylesheet(this);">
@endif
</head>
<body>
@@ -54,7 +61,14 @@
// 키가 새 버전으로 전환되어 routes.json/lang 변경이 즉시 가시화된다.
// 미주입 시 클라이언트가 항상 `v0` 으로 호출 → `template:cache-clear` 가 v 와일드카드를
// 처리하지 못해 캐시가 영구 stale 되는 결함이 발생.
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }},
// 자산 URL 모드 — 'extension'(기본) | 'extensionless'.
// 정적 최적화 블록이 동적 응답을 가로채는 서버에서 확장자 없는 형태로 전환.
// 부트스트랩 자가 복구가 런타임에 뒤집으므로 최상위 키로 노출한다.
// 자가 복구가 <head> 에서 이미 전환을 확정했다면 그 값을 잇는다.
// 이 대입은 G7Config 객체를 통째로 교체하므로, 독립 전역을 읽지 않으면
// 복원/전환 결과가 여기서 덮여 사라진다.
assetUrlMode: window.__g7AssetUrlMode || '{{ \App\Support\AssetUrl::mode() }}'
};
@if(isset($errorCode) && isset($errorLayout))
// 에러 상태 정보 (503 의존성 미충족 등)
@@ -71,8 +85,11 @@
{{-- 코어 엔진 + 템플릿 컴포넌트 번들 로드 → 초기화 (재시도 + 폴백 UI) --}}
@include('partials.bootstrap-scripts', [
'templateType' => 'admin',
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . filemtime(public_path('build/core/template-engine.min.js')),
'componentsSrc' => '/api/templates/assets/' . $activeAdminTemplate . '/js/components.iife.js?v=' . $extensionCacheVersion,
{{-- 코어 엔진 번들은 public/ 의 실물 정적 파일이라 자산 URL 이중 모드 대상이 아니다.
미빌드 상태에서 filemtime() 이 warning 을 내지 않도록 file_exists 가드
(coreEditorAsset/coreDevToolsAsset 와 동일 패턴). --}}
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . (file_exists(public_path('build/core/template-engine.min.js')) ? filemtime(public_path('build/core/template-engine.min.js')) : 0),
'componentsSrc' => \App\Support\AssetUrl::templateAsset($activeAdminTemplate, 'js/components.iife.js', $extensionCacheVersion),
'initConfig' => array_filter([
'templateId' => $activeAdminTemplate,
'templateType' => 'admin',
+23 -4
View File
@@ -16,9 +16,16 @@
@include('partials.error-fallback-styles')
@endif
{{-- 자산 URL 자가 복구 헬퍼 — CSS <link> 의 onerror 보다 먼저 정의되어야 한다 --}}
@include('partials.asset-url-recovery')
<!-- 템플릿 컴포넌트 스타일 -->
@if(!empty($activeUserTemplate))
<link rel="stylesheet" href="/api/templates/assets/{{ $activeUserTemplate }}/css/components.css?v={{ $extensionCacheVersion }}">
{{-- onerror: 정적 최적화 서버에서 확장자 붙은 CSS 가 가로채였을 때
확장자 없는 형태로 1회 교체한다(무스타일 화면 방지). 링크당 1회. --}}
<link rel="stylesheet"
href="{{ \App\Support\AssetUrl::templateAsset($activeUserTemplate, 'css/components.css', $extensionCacheVersion) }}"
onerror="window.__g7AssetUrl && window.__g7AssetUrl.recoverStylesheet(this);">
@endif
</head>
<body>
@@ -51,7 +58,16 @@
coreDevToolsAsset: '{{ asset('build/core/devtools.min.js') }}?v={{ file_exists(public_path('build/core/devtools.min.js')) ? filemtime(public_path('build/core/devtools.min.js')) : 0 }}',
// 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수.
// 자세한 설명은 admin.blade.php 참조.
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }},
// 자산 URL 모드 — 'extension'(기본) | 'extensionless'.
// 정적 최적화 블록(location ~* \.(js|css|json)$)이 동적 응답을 가로채는
// 서버에서 확장자 없는 형태로 전환한다. 부트스트랩 자가 복구가 실패 시
// 이 값을 런타임에 뒤집으므로 최상위 키로 노출한다(설정값 SSoT 는
// G7Config.settings.general.asset_url_mode).
// 자가 복구가 <head> 에서 이미 전환을 확정했다면 그 값을 잇는다.
// 이 대입은 G7Config 객체를 통째로 교체하므로, 독립 전역을 읽지 않으면
// 복원/전환 결과가 여기서 덮여 사라진다.
assetUrlMode: window.__g7AssetUrlMode || '{{ \App\Support\AssetUrl::mode() }}'
};
@if(isset($errorCode) && isset($errorLayout))
// 에러 상태 정보 (503 의존성 미충족 등)
@@ -68,8 +84,11 @@
{{-- 코어 엔진 + 템플릿 컴포넌트 번들 로드 → 초기화 (재시도 + 폴백 UI) --}}
@include('partials.bootstrap-scripts', [
'templateType' => 'user',
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . filemtime(public_path('build/core/template-engine.min.js')),
'componentsSrc' => '/api/templates/assets/' . $activeUserTemplate . '/js/components.iife.js?v=' . $extensionCacheVersion,
{{-- 코어 엔진 번들은 public/ 의 실물 정적 파일이라 자산 URL 이중 모드 대상이 아니다.
미빌드 상태에서 filemtime() 이 warning 을 내지 않도록 file_exists 가드
(coreEditorAsset/coreDevToolsAsset 와 동일 패턴). --}}
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . (file_exists(public_path('build/core/template-engine.min.js')) ? filemtime(public_path('build/core/template-engine.min.js')) : 0),
'componentsSrc' => \App\Support\AssetUrl::templateAsset($activeUserTemplate, 'js/components.iife.js', $extensionCacheVersion),
'initConfig' => array_filter([
'templateId' => $activeUserTemplate,
'templateType' => 'user',
+4
View File
@@ -844,6 +844,10 @@ if (isset($_GET['ajax_action'])) {
<span>SEO 캐시 삭제</span>
<span class="text-[10px] opacity-60">(seo:clear)</span>
</button>
<button onclick="runCommand('g7:asset-url-mode')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-slate-600 hover:bg-slate-700 text-white text-xs font-medium rounded transition-colors">
<span>자산 URL 방식 확인</span>
<span class="text-[10px] opacity-60">(g7:asset-url-mode)</span>
</button>
<button onclick="runCommand('seo:stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
<span>SEO 통계</span>
<span class="text-[10px] opacity-60">(seo:stats)</span>
@@ -0,0 +1,177 @@
{{--
자산 URL 모드 자가 복구 — 공용 헬퍼 (이슈 #486 §5)
`<head>` 의 CSS `<link>` 보다 **먼저** 로드되어야 한다. CSS 링크의 onerror 가
이 헬퍼를 호출하기 때문이며, 부트스트랩 스크립트(`<body>`)도 같은 헬퍼를 재사용한다.
코어 번들 로드 전에 실행되므로 외부 의존성 없는 순수 인라인 JS 여야 한다
(닭-달걀: 코어가 없으면 코어를 받아올 코드도 없다).
## 배경
nginx 의 정적 최적화 블록은 정규식 location 이라 프리픽스 location 보다 먼저
매칭된다. 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 엔드포인트가 PHP 에
도달하지 못한 채 404 가 된다. 관리자 화면조차 뜨지 않으므로 "설정을 바꾸세요"
안내는 순환 참조 — 브라우저가 스스로 복구해야 한다.
## 불변식 (계획서 §12)
L1 전환은 단방향(extension → extensionless) 1회. 역방향 폴백을 만들지 않는다.
L3 자동 location.reload() 금지.
L5 서버 설정은 건드리지 않는다 (미인증 클라이언트의 전역 설정 변경 금지).
L7 localStorage 캐시는 cache_version 을 키에 포함하고 TTL 을 둔다.
--}}
<script>
(function () {
'use strict';
var MODE_EXTENSIONLESS = 'extensionless';
var FILE_QUERY_PARAM = 'file';
var STORAGE_TTL_MS = 24 * 60 * 60 * 1000;
// cache_version 은 blade 에서 직접 주입한다. 이 파샬은 <head> 에서 실행되어
// window.G7Config(<body> 에서 정의) 가 아직 없기 때문 — 참조하면 항상 0 이 되어
// 캐시 키가 어긋난다.
var CACHE_VERSION = {{ (int) ($extensionCacheVersion ?? 0) }};
function storageKey() {
return 'g7_asset_url_mode:' + CACHE_VERSION;
}
/**
* 확장자 형태 API URL 을 확장자 없는 형태로 변환한다.
*
* `resources/js/core/support/assetUrl.ts::convertToCurrentMode` 와 **동일 규칙**이어야
* 한다. 여기는 코어 번들 로드 전이라 import 가 불가능해 인라인으로 둔다 — 드리프트는
* `assetUrl.test.ts` 의 대조 케이스가 잡는다.
*
* 변환 대상이 아니면 null. 코어 엔진 번들(`/build/...`)은 public/ 의 실물 정적
* 파일이라 변환하면 오히려 깨지므로 대상에서 제외된다.
*
* @param url 원본 URL
* @returns 변환된 URL 또는 null
*/
function toExtensionless(url) {
if (!url) return null;
// `<script>.src` / `<link>.href` 는 **절대 URL** 을 돌려준다
// (`https://host/api/...`). same-origin 이면 경로만 남긴다 — 이 정규화를
// 빠뜨리면 부트스트랩 재시도 경로에서 변환이 항상 null 이 되어 자가 복구가
// 통째로 죽는다(외형은 "3회 재시도 후 폴백 UI" 라 원인이 드러나지 않는다).
var origin = window.location && window.location.origin;
if (origin && url.indexOf(origin) === 0) {
url = url.slice(origin.length);
}
if (url.indexOf('/api/') !== 0) return null;
var qi = url.indexOf('?');
var path = qi === -1 ? url : url.slice(0, qi);
var query = qi === -1 ? '' : url.slice(qi + 1);
var bundle = path.match(/^\/api\/(modules|plugins)\/bundle\.(js|css)$/);
if (bundle) {
return '/api/' + bundle[1] + '/bundle/' + bundle[2] + (query ? '?' + query : '');
}
var asset = path.match(/^\/api\/(templates|modules|plugins)\/assets\/([^/]+)\/(.+)$/);
if (asset) {
return '/api/' + asset[1] + '/assets/' + asset[2] +
'?' + FILE_QUERY_PARAM + '=' + encodeURIComponent(decodeURIComponent(asset[3])) +
(query ? '&' + query : '');
}
var suffixed = path.match(/^(\/api\/.+)\.(json|js|css)$/);
if (suffixed) {
return suffixed[1] + (query ? '?' + query : '');
}
return null;
}
/**
* 모드 전환을 1회 확정한다 (L1 — 단방향 1회).
*
* 서버 설정은 바꾸지 않는다(L5). 전역 플래그와 localStorage 캐시만 갱신하며,
* 이후 엔진의 모든 fetch 가 이 값을 읽어 올바른 형태를 쓴다.
*
* @returns 이번 호출로 전환되었으면 true
*/
function switchToExtensionless() {
if (window.__g7AssetUrlMode === MODE_EXTENSIONLESS) return false;
// 독립 전역에 기록한다. <body> 의 `window.G7Config = {...}` 대입이 이 파샬보다
// 나중에 실행되어 객체를 통째로 교체하므로, G7Config 에만 쓰면 덮여 사라진다.
// G7Config 는 이 값을 초기값으로 읽어간다(app/admin blade 참조).
window.__g7AssetUrlMode = MODE_EXTENSIONLESS;
if (window.G7Config) window.G7Config.assetUrlMode = MODE_EXTENSIONLESS;
try {
window.localStorage.setItem(storageKey(), JSON.stringify({
mode: MODE_EXTENSIONLESS,
at: Date.now()
}));
} catch (e) { /* 저장 실패는 치명적이지 않다 */ }
return true;
}
/**
* 이전 방문에서 확정된 모드를 복원한다 (L7).
*
* cache_version 을 키에 포함하고 TTL 을 둬서 서버가 정상화된 뒤에도
* 클라이언트가 옛 모드에 영구 고착되지 않도록 한다.
*/
function restoreCachedMode() {
try {
var raw = window.localStorage.getItem(storageKey());
if (!raw) return false;
var parsed = JSON.parse(raw);
if (!parsed || parsed.mode !== MODE_EXTENSIONLESS) return false;
if (typeof parsed.at !== 'number' || Date.now() - parsed.at > STORAGE_TTL_MS) {
window.localStorage.removeItem(storageKey());
return false;
}
window.__g7AssetUrlMode = MODE_EXTENSIONLESS;
if (window.G7Config) window.G7Config.assetUrlMode = MODE_EXTENSIONLESS;
return true;
} catch (e) {
return false;
}
}
/**
* CSS `<link>` 로드 실패 시 확장자 없는 형태로 1회 교체한다.
*
* 스타일 부재는 앱을 죽이지 않으므로(엔진의 CSS 로더도 실패를 resolve 로 처리)
* 재교체가 실패해도 조용히 끝낸다 — 폴백 UI 를 띄우지 않는다(과잉 적용 경계).
* 교체는 링크당 1회만 (`data-g7-recovered` 마킹).
*
* @param link 실패한 <link> 요소
*/
function recoverStylesheet(link) {
if (!link || link.getAttribute('data-g7-recovered') === '1') return;
link.setAttribute('data-g7-recovered', '1');
var converted = toExtensionless(link.getAttribute('href') || '');
if (!converted) return;
switchToExtensionless();
link.href = converted;
}
restoreCachedMode();
window.__g7AssetUrl = {
MODE_EXTENSIONLESS: MODE_EXTENSIONLESS,
toExtensionless: toExtensionless,
switchToExtensionless: switchToExtensionless,
restoreCachedMode: restoreCachedMode,
recoverStylesheet: recoverStylesheet
};
})();
</script>
@@ -38,11 +38,37 @@
window.addEventListener('pagehide', function () { window.__g7Unloading = true; });
window.addEventListener('pageshow', function (e) { if (e.persisted) window.__g7Unloading = false; });
// ─────────────────────────────────────────────────────────────────────────
// 자산 URL 모드 자가 복구 (이슈 #486)
//
// nginx 의 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라
// 프리픽스 location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은
// 동적 엔드포인트가 PHP 에 도달하지 못한 채 404 가 된다. 이 경우 관리자 화면조차
// 뜨지 않으므로 "설정을 바꾸세요" 안내는 순환 참조다 — 브라우저가 스스로 복구한다.
//
// 불변식 (계획서 §12):
// L1 전환은 단방향(extension → extensionless) 1회. 역방향 폴백을 만들지 않는다.
// 양쪽 다 실패하는 상황(PHP 다운·WAF)에서 무한 왕복이 되기 때문.
// L2 전환 시도는 기존 MAX_ATTEMPTS 예산 안에서 소비한다(별도 예산 신설 금지).
// L3 자동 location.reload() 금지. 새로고침은 폴백 UI 의 사용자 클릭만.
// L4 모든 실패 경로는 renderFallback() 으로 수렴하고 종료한다.
// L5 서버 설정은 건드리지 않는다(미인증 클라이언트의 전역 설정 변경 금지).
// L8 pending 카운터 — 실패 element 제거 후 교체, onload 는 정확히 1회만 감소.
// ─────────────────────────────────────────────────────────────────────────
// 자산 URL 모드 자가 복구 헬퍼 — `partials/asset-url-recovery` 가 <head> 에서
// 미리 정의한다(CSS <link> 의 onerror 가 그것을 먼저 쓰기 때문). 여기서는 재사용만 한다.
// 헬퍼가 없으면(부분 include 등) 자가 복구 없이 기존 재시도 로직으로만 동작한다.
var assetUrl = window.__g7AssetUrl || null;
// 부트스트랩 상태 — 정적 <script> 의 onerror/onload 가 여기에 기록한다
var bootstrap = window.__g7Bootstrap = {
failed: false,
pending: 0,
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
modeSwitched: false,
/**
* 정적 <script> 로드 실패 시 동적 재시도를 시작한다.
*
@@ -64,6 +90,19 @@
return;
}
// 자산 URL 모드 전환 (L1·L2) — 확장자 형태가 실패했고 아직 전환 전이라면,
// 지연 재시도 대신 확장자 없는 형태로 **즉시 1회** 시도한다.
// 이 시도는 기존 예산(attempt)을 그대로 소비하므로 총 시도 횟수는 불변이다.
var converted = (bootstrap.modeSwitched || !assetUrl) ? null : assetUrl.toExtensionless(src);
if (converted && converted !== src) {
bootstrap.modeSwitched = true;
assetUrl.switchToExtensionless();
console.warn(LABEL + ' Retrying with extensionless URL: ' + converted);
bootstrap.replaceScript(converted, attempt);
return;
}
var delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
console.warn(LABEL + ' Script load failed (attempt ' + attempt + '/' + MAX_ATTEMPTS + '), retrying in ' + delay + 'ms: ' + src);
@@ -83,6 +122,33 @@
}, delay);
},
/**
* 스크립트를 지연 없이 즉시 교체 삽입한다 (모드 전환 재시도용).
*
* L8 — onload 는 pending 을 정확히 1회만 감소시키고, 실패한 element 는
* 제거한 뒤 다음 경로로 넘어간다. 실패 시에도 attempt 를 증가시켜
* MAX_ATTEMPTS 예산을 공유하므로 총 네트워크 시도 횟수가 늘지 않는다(L2).
*
* @param src 삽입할 스크립트 URL
* @param attempt 현재 시도 번호
*/
replaceScript: function (src, attempt) {
if (window.__g7Unloading) return;
var script = document.createElement('script');
script.src = src;
script.async = false; // 삽입 순서대로 실행 (코어 → 컴포넌트 순서 보장)
script.onload = function () {
bootstrap.pending -= 1;
bootstrap.tryInit();
};
script.onerror = function () {
if (script.parentNode) script.parentNode.removeChild(script);
bootstrap.retry(src, attempt + 1);
};
document.head.appendChild(script);
},
/**
* 부트스트랩 최종 실패 시 사용자에게 보이는 정적 폴백을 심는다.
*
+41 -27
View File
@@ -47,6 +47,7 @@ use App\Http\Controllers\Api\Auth\AuthController as UserAuthController;
use App\Http\Controllers\Api\Auth\NotificationController as UserNotificationController;
use App\Http\Controllers\Api\Auth\ProfileController as UserProfileController;
use App\Http\Controllers\Api\Identity\IdentityVerificationController;
use App\Http\Controllers\Api\Public\AssetProbeController;
use App\Http\Controllers\Api\Public\LayoutPreviewController;
use App\Http\Controllers\Api\Public\LocaleController as PublicLocaleController;
use App\Http\Controllers\Api\Public\PublicAttachmentController;
@@ -77,25 +78,29 @@ use Illuminate\Support\Facades\Route;
Route::group([], function () {
// 템플릿 라우트 정보 조회
Route::prefix('templates')->group(function () {
Route::get('{identifier}/routes.json', [PublicTemplateController::class, 'getRoutes'])->name('api.public.templates.routes');
// 아래 dual* 매크로는 확장자 형태와 확장자 없는 형태를 동시에 등록한다.
// 정적 최적화 블록(location ~* \.(js|css|json)$)이 있는 서버에서 동적 응답이
// nginx 에 가로채이지 않도록 하기 위함. 상세: App\Support\Routing\DualExtensionRoute
Route::dualSuffix('{identifier}/routes', 'json', [PublicTemplateController::class, 'getRoutes'])
->name('api.public.templates.routes');
// 템플릿 설정 파일 서빙 (error_config 등)
Route::get('{identifier}/config.json', [PublicTemplateController::class, 'serveConfig'])->name('api.public.templates.config');
Route::dualSuffix('{identifier}/config', 'json', [PublicTemplateController::class, 'serveConfig'])
->name('api.public.templates.config');
// 레이아웃 편집기 스펙 조회
Route::get('{identifier}/editor-spec', [PublicTemplateController::class, 'serveEditorSpec'])->name('api.public.templates.editor_spec');
// 템플릿 정적 파일 서빙
Route::get('assets/{identifier}/{path}', [PublicTemplateController::class, 'serveAsset'])
->where('path', '.*')
// 템플릿 정적 파일 서빙 (확장자 없는 형태는 ?file= 로 경로 수신)
Route::dualAsset('assets/{identifier}', [PublicTemplateController::class, 'serveAsset'])
->name('api.public.templates.assets');
// 컴포넌트 정의 파일 서빙
Route::get('{identifier}/components.json', [PublicTemplateController::class, 'serveComponents'])
Route::dualSuffix('{identifier}/components', 'json', [PublicTemplateController::class, 'serveComponents'])
->name('api.public.templates.components');
// 다국어 파일 서빙
Route::get('{identifier}/lang/{locale}.json', [PublicTemplateController::class, 'serveLanguage'])
Route::dualSuffix('{identifier}/lang/{locale}', 'json', [PublicTemplateController::class, 'serveLanguage'])
->where('locale', '[a-z]{2}(-[A-Z]{2})?')
->name('api.public.templates.language');
@@ -114,11 +119,13 @@ Route::group([], function () {
->group(function () {
// 레이아웃 미리보기 서빙 (토큰 기반, 인증 불필요)
// 주의: 일반 레이아웃 서빙보다 먼저 정의 (preview가 templateIdentifier로 매칭되는 것 방지)
Route::get('preview/{token}.json', [LayoutPreviewController::class, 'serve'])
Route::dualSuffix('preview/{token}', 'json', [LayoutPreviewController::class, 'serve'])
->where('token', '[a-f0-9\-]{36}')
->name('api.public.layouts.preview.serve');
Route::get('{templateIdentifier}/{layoutName}.json', [PublicLayoutController::class, 'serve'])
// 주의: dualSuffix 는 확장자 형태를 먼저 등록한다. layoutName 정규식이 `.` 를
// 포함해 greedy 하므로, 확장자 없는 형태가 먼저 등록되면 `.json` 요청까지 삼킨다.
Route::dualSuffix('{templateIdentifier}/{layoutName}', 'json', [PublicLayoutController::class, 'serve'])
->where('layoutName', '[a-zA-Z0-9_/\.-]+')
->name('api.public.layouts.serve');
});
@@ -126,13 +133,13 @@ Route::group([], function () {
// 모듈 에셋 서빙 API
Route::prefix('modules')->group(function () {
// 활성 모듈 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록)
Route::get('bundle.js', [PublicModuleController::class, 'serveBundleJs'])
// 접미사가 js/css 를 구분하므로 제거 불가 — 세그먼트로 내린다 (bundle/js, bundle/css).
Route::dualSuffixSegment('bundle', 'js', [PublicModuleController::class, 'serveBundleJs'])
->name('api.public.modules.bundle.js');
Route::get('bundle.css', [PublicModuleController::class, 'serveBundleCss'])
Route::dualSuffixSegment('bundle', 'css', [PublicModuleController::class, 'serveBundleCss'])
->name('api.public.modules.bundle.css');
Route::get('assets/{identifier}/{path}', [PublicModuleController::class, 'serveAsset'])
->where('path', '.*')
Route::dualAsset('assets/{identifier}', [PublicModuleController::class, 'serveAsset'])
->name('api.public.modules.assets');
// 레이아웃 편집기 스펙 조회
@@ -140,20 +147,19 @@ Route::group([], function () {
->name('api.public.modules.editor_spec');
// 컴포넌트 정의 파일 서빙 (module:build 산출물)
Route::get('{identifier}/components.json', [PublicModuleController::class, 'serveComponents'])
Route::dualSuffix('{identifier}/components', 'json', [PublicModuleController::class, 'serveComponents'])
->name('api.public.modules.components');
});
// 플러그인 에셋 서빙 API
Route::prefix('plugins')->group(function () {
// 활성 플러그인 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록)
Route::get('bundle.js', [PublicPluginController::class, 'serveBundleJs'])
Route::dualSuffixSegment('bundle', 'js', [PublicPluginController::class, 'serveBundleJs'])
->name('api.public.plugins.bundle.js');
Route::get('bundle.css', [PublicPluginController::class, 'serveBundleCss'])
Route::dualSuffixSegment('bundle', 'css', [PublicPluginController::class, 'serveBundleCss'])
->name('api.public.plugins.bundle.css');
Route::get('assets/{identifier}/{path}', [PublicPluginController::class, 'serveAsset'])
->where('path', '.*')
Route::dualAsset('assets/{identifier}', [PublicPluginController::class, 'serveAsset'])
->name('api.public.plugins.assets');
// 레이아웃 편집기 스펙 조회
@@ -161,7 +167,7 @@ Route::group([], function () {
->name('api.public.plugins.editor_spec');
// 컴포넌트 정의 파일 서빙 (plugin:build 산출물)
Route::get('{identifier}/components.json', [PublicPluginController::class, 'serveComponents'])
Route::dualSuffix('{identifier}/components', 'json', [PublicPluginController::class, 'serveComponents'])
->name('api.public.plugins.components');
});
@@ -177,6 +183,12 @@ Route::group([], function () {
// 활성 로케일 목록 — 언어팩 설치/활성화 직후 셀렉터 즉시 갱신용
Route::get('locales/active', [PublicLocaleController::class, 'active'])
->name('api.public.locales.active');
// 자산 URL 모드 감지 프로브 — 정적 최적화 블록이 확장자 붙은 동적 응답을
// 가로채는지 쌍으로 판정한다. 매크로 자기적용(확장자 형태 + 대조군).
// DB 미접근·무인증·no-store. 상세: App\Http\Controllers\Api\Public\AssetProbeController
Route::dualSuffix('system/asset-probe', 'js', [AssetProbeController::class, 'probe'])
->name('api.public.system.asset-probe');
});
// 브로드캐스팅 인증 (Sanctum 토큰 사용)
@@ -757,32 +769,34 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
Route::get('{identifier}/editor-assets', [AdminTemplateAssetController::class, 'getEditorAssets'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-assets');
Route::get('{identifier}/editor/components.json', [AdminTemplateAssetController::class, 'serveComponents'])
Route::dualSuffix('{identifier}/editor/components', 'json', [AdminTemplateAssetController::class, 'serveComponents'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-components');
Route::get('{identifier}/editor/routes.json', [AdminTemplateAssetController::class, 'serveRoutes'])
Route::dualSuffix('{identifier}/editor/routes', 'json', [AdminTemplateAssetController::class, 'serveRoutes'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-routes');
Route::get('{identifier}/editor/editor-spec.json', [AdminTemplateAssetController::class, 'serveEditorSpec'])
Route::dualSuffix('{identifier}/editor/editor-spec', 'json', [AdminTemplateAssetController::class, 'serveEditorSpec'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-spec');
Route::get('{identifier}/editor/lang/{locale}.json', [AdminTemplateAssetController::class, 'serveLanguage'])
Route::dualSuffix('{identifier}/editor/lang/{locale}', 'json', [AdminTemplateAssetController::class, 'serveLanguage'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-lang');
// 표시 권한 후보 — 코어 + 활성 확장 권한 (속성 모달 표시 권한 TagInput).
// 편집 권한 가드 하에서만 노출(전역 G7Config 상시 노출 회피).
Route::get('{identifier}/editor/permission-candidates.json', [AdminTemplateAssetController::class, 'servePermissionCandidates'])
Route::dualSuffix('{identifier}/editor/permission-candidates', 'json', [AdminTemplateAssetController::class, 'servePermissionCandidates'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-permission-candidates');
// 편집기 프리뷰 전용 CSS — 다크 셀렉터를 프리뷰 마커(.g7le-preview-dark)로 치환해 서빙
// 관리자 admin 의 html.dark 조상과 독립적으로 프리뷰 라이트/다크 격리.
Route::get('{identifier}/editor/components.css', [AdminTemplateAssetController::class, 'serveEditorCss'])
// URI 가 `components` 가 아니라 `component-styles` 인 이유: 확장자를 떼면
// `editor/components.json` 의 확장자 없는 형태와 충돌한다.
Route::dualSuffix('{identifier}/editor/component-styles', 'css', [AdminTemplateAssetController::class, 'serveEditorCss'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-css');
// 페이지 설정 모달 — SEO 후보/미리보기 + 브로드캐스트 카탈로그.
// 전부 편집 권한 가드 하 편집기 전용. {templateName} 동적 라우트보다 위에 둠.
Route::get('{identifier}/editor/seo-candidates.json', [SeoCandidateController::class, 'index'])
Route::dualSuffix('{identifier}/editor/seo-candidates', 'json', [SeoCandidateController::class, 'index'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-seo-candidates');
Route::post('{identifier}/editor/seo-og-preview', [SeoOgPreviewController::class, 'show'])
@@ -791,7 +805,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
Route::post('{identifier}/editor/seo-bot-preview', [SeoBotPreviewController::class, 'show'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-seo-bot-preview');
Route::get('{identifier}/editor/broadcast-catalog.json', [BroadcastCatalogController::class, 'index'])
Route::dualSuffix('{identifier}/editor/broadcast-catalog', 'json', [BroadcastCatalogController::class, 'index'])
->middleware('permission:admin,core.templates.layouts.edit')
->name('api.admin.templates.editor-broadcast-catalog');
@@ -8,6 +8,7 @@
### Added
- 일반 설정에 자산 파일 주소 방식 항목을 추가했습니다. 일부 서버 설정에서 `.js`/`.css`/`.json` 주소가 가로채여 화면이 뜨지 않을 때 확장자 없는 주소로 전환할 수 있습니다. 평소에는 자동으로 선택되므로 화면이 정상이면 바꿀 필요가 없습니다.
- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다.
- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다.
- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다.
@@ -0,0 +1,43 @@
/**
* detectAssetUrlMode 핸들러 (이슈 #486 §8)
*
* 관리자 환경설정에서 자산 URL 방식을 **브라우저에서** 재감지한다.
*
* 서버측에서 자기 `APP_URL` 로 curl 하면 loopback 이 nginx vhost·SSL·프록시 체인을
* 우회하거나 다른 vhost 를 타서 오판한다. 실제로 방문자가 겪는 경로를 재현하려면
* 브라우저가 던져야 한다.
*
* 감지 지점이 인스톨러 하나로 부족한 이유가 여기 있다 — 관리자가 설치 6개월 뒤
* 정적 최적화 블록을 추가하면 저장된 값은 "확장자 OK" 인 채 사이트가 다시 죽는다.
* 이 버튼이 그 상황의 재판정 수단이다.
*/
/** 판정 결과 */
export type DetectResult = 'extension' | 'extensionless' | 'unavailable';
/**
* 프로브 쌍을 던져 자산 URL 방식을 판정한다.
*
* 단일 프로브는 "PHP 자체가 죽음" 과 구분되지 않으므로 반드시 쌍으로 던진다.
*
* | probe.js | probe | 판정 |
* |---|---|---|
* | 성공 | 성공 | `extension` |
* | 실패 | 성공 | `extensionless` (정적 블록 가로채기 확정) |
* | 그 외 | | `unavailable` (모드 문제 아님 — PHP/라우팅 장애) |
*
* @returns 판정 결과
*/
export declare function detectAssetUrlMode(): Promise<DetectResult>;
/**
* 자산 URL 방식 자동 감지 핸들러.
*
* 판정 결과를 폼 상태(`general.asset_url_mode`)에 반영하고 토스트로 안내한다.
* **저장은 하지 않는다** — 관리자가 결과를 보고 저장 버튼을 누르는 흐름을 유지해,
* 감지가 오판했을 때 되돌릴 여지를 남긴다.
*
* 판정 불가(`unavailable`)면 값을 건드리지 않는다. 서버가 응답하지 않는 상황에서
* 임의 값을 넣으면 멀쩡한 설정을 덮어쓸 수 있다.
*
* @param _action 액션 정의 (미사용)
* @param context 액션 컨텍스트 (setState 등)
*/
export declare function detectAssetUrlModeHandler(_action: any, context?: any): Promise<void>;
@@ -6,6 +6,7 @@ import { saveMultilingualTagHandler, cancelMultilingualTagHandler, updateMultili
import { setDateRangeHandler } from './setDateRangeHandler';
import { toggleSidebarHandler, initSidebarHandler } from './sidebarHandler';
import { downloadAttachmentHandler } from './downloadAttachment';
import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
/**
* 핸들러 맵
*
@@ -15,6 +16,7 @@ import { downloadAttachmentHandler } from './downloadAttachment';
* 새로운 핸들러 추가 시 여기에만 등록하면 자동으로 ActionDispatcher에 등록됩니다.
*/
export declare const handlerMap: {
readonly detectAssetUrlMode: typeof detectAssetUrlModeHandler;
readonly setTheme: typeof setThemeHandler;
readonly initTheme: typeof initThemeHandler;
readonly scrollToSection: typeof scrollToSectionHandler;
@@ -1307,7 +1307,16 @@
"default_language": "Default Language",
"select_language": "Select a language",
"timezone": "Timezone",
"select_timezone": "Select a timezone"
"select_timezone": "Select a timezone",
"asset_url_mode": "Asset file URL style",
"asset_url_mode_help": "Chosen automatically based on your server setup. Leave it alone if pages load correctly.",
"asset_url_mode_extension": "Use file extensions (recommended)",
"asset_url_mode_extensionless": "No file extensions",
"asset_url_mode_detect": "Detect automatically",
"asset_url_mode_detecting": "Detecting asset URL style...",
"asset_url_mode_detect_extension": "File extensions work fine on this server.",
"asset_url_mode_detect_extensionless": "The server is intercepting extension URLs. Switch to no extensions and save.",
"asset_url_mode_detect_unavailable": "Detection failed. The server did not respond, so the style could not be determined."
},
"mail": {
"smtp_settings": "SMTP Settings",
@@ -1311,7 +1311,16 @@
"default_language": "기본 언어",
"select_language": "언어를 선택하세요",
"timezone": "시간대",
"select_timezone": "시간대를 선택하세요"
"select_timezone": "시간대를 선택하세요",
"asset_url_mode": "자산 파일 주소 방식",
"asset_url_mode_help": "서버 설정에 따라 자동으로 선택됩니다. 화면이 정상적으로 보인다면 바꾸지 마세요.",
"asset_url_mode_extension": "확장자 사용 (권장)",
"asset_url_mode_extensionless": "확장자 미사용",
"asset_url_mode_detect": "자동 감지",
"asset_url_mode_detecting": "자산 주소 방식을 감지하는 중입니다...",
"asset_url_mode_detect_extension": "확장자를 사용할 수 있는 환경입니다.",
"asset_url_mode_detect_extensionless": "서버가 확장자 주소를 가로채고 있습니다. 확장자 미사용으로 바꾼 뒤 저장하세요.",
"asset_url_mode_detect_unavailable": "감지에 실패했습니다. 서버가 응답하지 않아 방식을 판정할 수 없습니다."
},
"mail": {
"smtp_settings": "SMTP 설정",
@@ -463,6 +463,94 @@
"text": "{{_local.errors?.['general.timezone']?.[0] ?? ''}}"
}
]
},
{
"id": "field_asset_url_mode",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.general.asset_url_mode"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-help"
},
"text": "$t:admin.settings.general.asset_url_mode_help"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-2 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "general.asset_url_mode",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}",
"options": [
{
"value": "extension",
"label": "$t:admin.settings.general.asset_url_mode_extension"
},
{
"value": "extensionless",
"label": "$t:admin.settings.general.asset_url_mode_extensionless"
}
]
}
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn-secondary whitespace-nowrap",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.general.asset_url_mode_detect"
}
],
"actions": [
{
"event": "onClick",
"handler": "detectAssetUrlMode"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,201 @@
/**
* detectAssetUrlMode 감지 로직 테스트 — 이슈 #486 §12 L6 전용 가드
*
* L6: "프로브 판정은 **본문 매직 토큰 + Content-Type** 기준"
*
* 이 불변식이 막는 사고는 구체적이다 — 일부 서버는 없는 경로에 404 대신
* `200 + 에러 HTML` 이나 catch-all 페이지를 반환한다. 상태코드만 보면 그 응답을
* "프로브 성공" 으로 읽어 영원히 `extension` 으로 오판하고, 관리자가 재감지를
* 몇 번 눌러도 같은 오답이 나온다.
*
* 로직·주석·구현은 실재하지만 **negative branch 회귀 가드가 없었다**. 즉 누군가
* Content-Type 검사나 토큰 검사를 지워도 아무 테스트도 red 가 되지 않는 상태였다.
* 본 파일이 그 구멍을 막는다.
*/
// e2e:allow 프로브 판정의 순수 분기 로직 단위. 브라우저 시나리오는 asset-url-mode.spec.ts 담당.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { detectAssetUrlMode } from '../detectAssetUrlModeHandler';
/** 서버 AssetProbeController::PROBE_TOKEN 과 동일해야 하는 값 */
const TOKEN = 'G7_ASSET_PROBE_OK';
/** 정상 프로브 응답 본문 */
const VALID_BODY = `/* G7 asset URL mode probe */\nwindow.__g7AssetProbe = '${TOKEN}';\n`;
/**
* fetch 응답을 흉내낸다.
*
* @param init 상태·본문·Content-Type
*/
function mockResponse(init: { ok?: boolean; body?: string; contentType?: string }) {
return {
ok: init.ok ?? true,
headers: { get: () => init.contentType ?? 'application/javascript; charset=utf-8' },
text: async () => init.body ?? VALID_BODY,
};
}
describe('detectAssetUrlMode — 프로브 판정 (§12 L6)', () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
(globalThis as any).fetch = fetchSpy;
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* 경로별 응답을 지정한다.
*
* @param withExt `/api/system/asset-probe.js` 응답
* @param withoutExt `/api/system/asset-probe` 응답
*/
const respond = (withExt: any, withoutExt: any) => {
fetchSpy.mockImplementation(async (url: string) =>
url.endsWith('.js') ? withExt : withoutExt,
);
};
describe('정상 판정', () => {
it('둘 다 성공하면 extension', async () => {
respond(mockResponse({}), mockResponse({}));
await expect(detectAssetUrlMode()).resolves.toBe('extension');
});
it('확장자 형태만 실패하면 extensionless (정적 블록 가로채기 확정)', async () => {
respond(mockResponse({ ok: false }), mockResponse({}));
await expect(detectAssetUrlMode()).resolves.toBe('extensionless');
});
it('둘 다 실패하면 unavailable (모드 문제 아님 — PHP/라우팅 장애)', async () => {
respond(mockResponse({ ok: false }), mockResponse({ ok: false }));
await expect(detectAssetUrlMode()).resolves.toBe('unavailable');
});
it('확장자 없는 형태만 실패해도 unavailable (전환해도 소용없는 상태)', async () => {
respond(mockResponse({}), mockResponse({ ok: false }));
await expect(detectAssetUrlMode()).resolves.toBe('unavailable');
});
});
describe('L6 — 상태코드만으로 판정하지 않는다', () => {
// 계획서 §12 L6 이 명시한 시나리오: "200-but-wrong-body 환경에서
// 재감지가 영원히 같은 오답" 을 내면 안 된다.
it('200 + 에러 HTML 을 성공으로 오판하지 않는다', async () => {
const errorPage = mockResponse({
ok: true,
body: '<html><body>404 Not Found</body></html>',
contentType: 'text/html; charset=utf-8',
});
// 확장자 형태가 catch-all HTML 을 받고, 확장자 없는 형태는 정상
respond(errorPage, mockResponse({}));
await expect(
detectAssetUrlMode(),
'200+HTML 을 프로브 성공으로 오판했다 — 정적 블록 가로채기를 놓친다',
).resolves.toBe('extensionless');
});
// 계획서 §"루프 방지 전용 테스트" L6 행의 **문자 그대로의 시나리오**:
// "프로브 응답을 200 + <html>에러페이지</html> 로 가로챈다
// → 감지 결과가 extension 이 아니라 '판정 불가/PHP 문제' 로 분기"
//
// 위의 '둘 다 실패' 케이스(ok:false)와는 **다른 코드 경로**다. 404 는
// `!res.ok` 에서 조기 반환되지만, 200+본문오류는 Content-Type·토큰 검사를
// 통과해야 걸러진다. 두 검사가 사라지면 이 케이스만 조용히 extension 으로
// 오판되고, 관리자는 재감지를 몇 번 눌러도 같은 오답을 받는다.
it('둘 다 200 + 에러 HTML 이면 판정 불가로 분기한다 (catch-all 서버)', async () => {
const catchAllPage = () =>
mockResponse({
ok: true,
body: '<html><body>Page Not Found</body></html>',
contentType: 'text/html; charset=utf-8',
});
respond(catchAllPage(), catchAllPage());
await expect(
detectAssetUrlMode(),
'catch-all 200 페이지를 프로브 성공으로 읽어 extension 으로 오판했다',
).resolves.toBe('unavailable');
});
it('Content-Type 이 스크립트가 아니면 토큰이 있어도 실패로 본다', async () => {
// 토큰을 그대로 담았지만 text/html 로 응답하는 catch-all 페이지
const htmlWithToken = mockResponse({
ok: true,
body: `<html><body>${TOKEN}</body></html>`,
contentType: 'text/html; charset=utf-8',
});
respond(htmlWithToken, mockResponse({}));
await expect(
detectAssetUrlMode(),
'Content-Type 검사가 없어 HTML 응답을 프로브 성공으로 읽었다',
).resolves.toBe('extensionless');
});
it('매직 토큰이 없으면 Content-Type 이 맞아도 실패로 본다', async () => {
// JS 로 응답하지만 우리 프로브가 아닌 다른 스크립트 (CDN 폴백 등)
const otherScript = mockResponse({
ok: true,
body: 'console.log("some other script");',
contentType: 'application/javascript',
});
respond(otherScript, mockResponse({}));
await expect(
detectAssetUrlMode(),
'토큰 검사가 없어 무관한 JS 응답을 프로브 성공으로 읽었다',
).resolves.toBe('extensionless');
});
it('application/javascript 외 스크립트 MIME 도 허용한다', async () => {
// 일부 서버는 text/javascript / application/ecmascript 로 응답한다
for (const contentType of ['text/javascript', 'application/ecmascript']) {
respond(mockResponse({ contentType }), mockResponse({ contentType }));
await expect(
detectAssetUrlMode(),
`${contentType} 를 스크립트로 인정하지 않았다`,
).resolves.toBe('extension');
}
});
});
describe('네트워크 예외', () => {
it('fetch 가 throw 해도 판정이 죽지 않는다', async () => {
fetchSpy.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(detectAssetUrlMode()).resolves.toBe('unavailable');
});
});
describe('프로브 요청 형태', () => {
it('두 경로를 쌍으로 던지고 캐시를 쓰지 않는다', async () => {
respond(mockResponse({}), mockResponse({}));
await detectAssetUrlMode();
const urls = fetchSpy.mock.calls.map((c) => c[0]);
expect(urls).toContain('/api/system/asset-probe.js');
expect(urls).toContain('/api/system/asset-probe');
for (const call of fetchSpy.mock.calls) {
expect(call[1]?.cache, '프로브가 캐시될 수 있는 요청으로 나갔다').toBe('no-store');
}
});
});
});
@@ -0,0 +1,135 @@
/**
* detectAssetUrlMode 핸들러 (이슈 #486 §8)
*
* 관리자 환경설정에서 자산 URL 방식을 **브라우저에서** 재감지한다.
*
* 서버측에서 자기 `APP_URL` 로 curl 하면 loopback 이 nginx vhost·SSL·프록시 체인을
* 우회하거나 다른 vhost 를 타서 오판한다. 실제로 방문자가 겪는 경로를 재현하려면
* 브라우저가 던져야 한다.
*
* 감지 지점이 인스톨러 하나로 부족한 이유가 여기 있다 — 관리자가 설치 6개월 뒤
* 정적 최적화 블록을 추가하면 저장된 값은 "확장자 OK" 인 채 사이트가 다시 죽는다.
* 이 버튼이 그 상황의 재판정 수단이다.
*/
const logger = ((window as any).G7Core?.createLogger?.('Handler:DetectAssetUrlMode')) ?? {
log: (...args: unknown[]) => console.log('[Handler:DetectAssetUrlMode]', ...args),
warn: (...args: unknown[]) => console.warn('[Handler:DetectAssetUrlMode]', ...args),
error: (...args: unknown[]) => console.error('[Handler:DetectAssetUrlMode]', ...args),
};
/** 프로브 응답 본문에 담기는 매직 토큰 (서버 AssetProbeController::PROBE_TOKEN 과 동일) */
const PROBE_TOKEN = 'G7_ASSET_PROBE_OK';
/** 판정 결과 */
export type DetectResult = 'extension' | 'extensionless' | 'unavailable';
/**
* 프로브 1건을 던져 성공 여부를 판정한다.
*
* 성공 판정은 상태코드가 아니라 **본문의 매직 토큰 + Content-Type** 이다.
* 상태코드만 보면 "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는
* 설정에서 영원히 `extension` 으로 오판해, 재감지를 몇 번 눌러도 같은 오답이 나온다.
*
* @param path 프로브 경로
* @returns 매직 토큰이 확인되면 true
*/
async function probe(path: string): Promise<boolean> {
try {
const res = await fetch(path, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) return false;
// Content-Type 이 스크립트가 아니면 정적 블록이 아닌 다른 것이 응답한 것
const contentType = res.headers.get('content-type') ?? '';
if (!/javascript|ecmascript/i.test(contentType)) return false;
return (await res.text()).includes(PROBE_TOKEN);
} catch (e) {
return false;
}
}
/**
* 프로브 쌍을 던져 자산 URL 방식을 판정한다.
*
* 단일 프로브는 "PHP 자체가 죽음" 과 구분되지 않으므로 반드시 쌍으로 던진다.
*
* | probe.js | probe | 판정 |
* |---|---|---|
* | 성공 | 성공 | `extension` |
* | 실패 | 성공 | `extensionless` (정적 블록 가로채기 확정) |
* | 그 외 | | `unavailable` (모드 문제 아님 — PHP/라우팅 장애) |
*
* @returns 판정 결과
*/
export async function detectAssetUrlMode(): Promise<DetectResult> {
const [withExt, withoutExt] = await Promise.all([
probe('/api/system/asset-probe.js'),
probe('/api/system/asset-probe'),
]);
if (withExt && withoutExt) return 'extension';
if (!withExt && withoutExt) return 'extensionless';
return 'unavailable';
}
/**
* 자산 URL 방식 자동 감지 핸들러.
*
* 판정 결과를 폼 상태(`general.asset_url_mode`)에 반영하고 토스트로 안내한다.
* **저장은 하지 않는다** — 관리자가 결과를 보고 저장 버튼을 누르는 흐름을 유지해,
* 감지가 오판했을 때 되돌릴 여지를 남긴다.
*
* 판정 불가(`unavailable`)면 값을 건드리지 않는다. 서버가 응답하지 않는 상황에서
* 임의 값을 넣으면 멀쩡한 설정을 덮어쓸 수 있다.
*
* @param _action 액션 정의 (미사용)
* @param context 액션 컨텍스트 (setState 등)
*/
export async function detectAssetUrlModeHandler(
_action: any,
context?: any,
): Promise<void> {
const G7Core = (window as any).G7Core;
try {
G7Core?.dispatch?.({
handler: 'toast',
params: { message: '$t:admin.settings.general.asset_url_mode_detecting', type: 'info' },
});
const result = await detectAssetUrlMode();
if (result === 'unavailable') {
G7Core?.dispatch?.({
handler: 'toast',
params: { message: '$t:admin.settings.general.asset_url_mode_detect_unavailable', type: 'error' },
});
return;
}
// 폼 상태에 반영 (저장은 관리자가 명시적으로 수행)
const setState = context?.setState ?? G7Core?.state?.setLocal;
if (typeof setState === 'function') {
setState({ 'general.asset_url_mode': result });
} else {
logger.warn('setState 를 찾지 못해 감지 결과를 폼에 반영하지 못했습니다');
}
G7Core?.dispatch?.({
handler: 'toast',
params: {
message: `$t:admin.settings.general.asset_url_mode_detect_${result}`,
type: result === 'extension' ? 'success' : 'warning',
},
});
} catch (e) {
logger.error('자산 URL 방식 감지 실패:', e);
G7Core?.dispatch?.({
handler: 'toast',
params: { message: '$t:admin.settings.general.asset_url_mode_detect_unavailable', type: 'error' },
});
}
}
@@ -24,6 +24,8 @@ import { setDateRangeHandler } from './setDateRangeHandler';
import { toggleSidebarHandler, initSidebarHandler } from './sidebarHandler';
// 게시판 첨부 다운로드 핸들러 (토큰 동반 → 활동이력 행위자 기록, #413 item 58b)
import { downloadAttachmentHandler } from './downloadAttachment';
// 자산 URL 방식 자동 감지 (이슈 #486) — 브라우저에서 프로브 쌍을 던져 재판정
import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
/**
* 핸들러 맵
@@ -35,6 +37,7 @@ import { downloadAttachmentHandler } from './downloadAttachment';
*/
export const handlerMap = {
// 언어: setLocale은 엔진 레벨(ActionDispatcher)에서 빌트인으로 처리
detectAssetUrlMode: detectAssetUrlModeHandler,
setTheme: setThemeHandler,
initTheme: initThemeHandler,
scrollToSection: scrollToSectionHandler,
@@ -2,7 +2,10 @@
namespace Tests\Feature\Api\Admin;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Http\Controllers\Api\Admin\AdminTemplateAssetController;
use App\Models\Module;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
@@ -10,6 +13,7 @@ use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
@@ -223,17 +227,17 @@ class AdminTemplateAssetControllerTest extends TestCase
// 모달 수집은 `getActiveModules()`(modules 테이블 status=active) 기준이므로
// ecommerce 모듈을 활성 상태로 시드한다. requiredExtensions 는 마이그레이션 경로만
// 등록하고 active 행을 만들지 않는다.
\App\Models\Module::query()->updateOrCreate(
Module::query()->updateOrCreate(
['identifier' => 'sirsoft-ecommerce'],
[
'vendor' => 'sirsoft',
'name' => 'E-Commerce',
'version' => '1.0.0',
'status' => \App\Enums\ExtensionStatus::Active->value,
'status' => ExtensionStatus::Active->value,
]
);
app(\App\Contracts\Repositories\ModuleRepositoryInterface::class); // 바인딩 보장
\Illuminate\Support\Facades\Cache::flush(); // 활성 식별자 캐시 무효화
app(ModuleRepositoryInterface::class); // 바인딩 보장
Cache::flush(); // 활성 식별자 캐시 무효화
$response = $this->withHeaders([
'Authorization' => "Bearer {$this->adminToken}",
@@ -430,7 +434,7 @@ class AdminTemplateAssetControllerTest extends TestCase
$response = $this->withHeaders([
'Authorization' => "Bearer {$this->adminToken}",
'Accept' => 'text/css',
])->get('/api/admin/templates/sirsoft-basic/editor/components.css');
])->get('/api/admin/templates/sirsoft-basic/editor/component-styles.css');
$response->assertStatus(200);
$this->assertStringContainsString('text/css', (string) $response->headers->get('Content-Type'));
@@ -467,7 +471,7 @@ class AdminTemplateAssetControllerTest extends TestCase
$response = $this->withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'text/css',
])->get('/api/admin/templates/sirsoft-basic/editor/components.css');
])->get('/api/admin/templates/sirsoft-basic/editor/component-styles.css');
$response->assertStatus(403);
}
@@ -486,7 +490,7 @@ class AdminTemplateAssetControllerTest extends TestCase
$response->assertStatus(200);
$css = $response->json('data.css');
if (! empty($css)) {
$this->assertStringContainsString('/editor/components.css', $css[0], 'CSS URL 은 편집기 전용 엔드포인트여야 한다');
$this->assertStringContainsString('/editor/component-styles.css', $css[0], 'CSS URL 은 편집기 전용 엔드포인트여야 한다');
}
}
@@ -0,0 +1,204 @@
<?php
namespace Tests\Feature\Api\Public;
use App\Enums\ExtensionStatus;
use App\Http\Controllers\Api\Public\AssetProbeController;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 자산 URL 이중 모드 서빙 계약 테스트 (이슈 #486 단위 A).
*
* 라우트가 두 형태로 등록되었다는 것만으로는 부족하다 — 확장자 없는 형태가
* 확장자 형태와 **같은 응답**을 주고 **같은 권한 가드**를 받아야 실제로 대체재가 된다.
* 여기서는 그 등가성을 실제 HTTP 요청으로 검증한다.
*/
class DualExtensionServingTest extends TestCase
{
use RefreshDatabase;
/**
* 감지 프로브의 확장자 형태가 매직 토큰을 반환해야 한다.
*/
public function test_프로브_확장자_형태가_매직_토큰을_반환한다(): void
{
$response = $this->get('/api/system/asset-probe.js');
$response->assertOk();
$this->assertStringContainsString(AssetProbeController::PROBE_TOKEN, $response->getContent());
$this->assertStringContainsString('javascript', $response->headers->get('Content-Type'));
}
/**
* 감지 프로브의 대조군(확장자 없는 형태)도 동일한 토큰을 반환해야 한다.
*/
public function test_프로브_대조군이_동일한_토큰을_반환한다(): void
{
$response = $this->get('/api/system/asset-probe');
$response->assertOk();
$this->assertStringContainsString(AssetProbeController::PROBE_TOKEN, $response->getContent());
}
/**
* 프로브는 캐시되지 않아야 한다.
*
* 캐시되면 관리자가 서버 설정을 고친 뒤 재감지해도 옛 판정이 반복된다.
*/
public function test_프로브_응답이_캐시되지_않는다(): void
{
foreach (['/api/system/asset-probe.js', '/api/system/asset-probe'] as $uri) {
$cacheControl = $this->get($uri)->headers->get('Cache-Control');
$this->assertStringContainsString('no-store', $cacheControl, "{$uri} 가 no-store 아님");
}
}
/**
* 템플릿 자산은 경로 세그먼트 형태와 `?file=` 형태가 동일한 내용을 반환해야 한다.
*/
public function test_템플릿_자산이_두_형태에서_동일한_내용을_반환한다(): void
{
$assetPath = 'js/components.iife.js';
if (! file_exists(base_path("templates/sirsoft-basic/dist/{$assetPath}"))) {
$this->markTestSkipped('sirsoft-basic 빌드 산출물이 없어 자산 서빙 등가성을 검증할 수 없습니다.');
}
// 자산 서빙은 활성 상태의 템플릿 레코드를 요구한다 (TemplateService::getAssetFilePath)
$this->activateTemplate('sirsoft-basic');
$viaSegment = $this->get("/api/templates/assets/sirsoft-basic/{$assetPath}");
$viaQuery = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode($assetPath));
$viaSegment->assertOk();
$viaQuery->assertOk();
$this->assertSame(
$viaSegment->streamedContent(),
$viaQuery->streamedContent(),
'경로 세그먼트 형태와 ?file= 형태의 응답 본문이 다르다'
);
}
/**
* `?file=` 형태도 경로 검증을 그대로 통과시켜서는 안 된다.
*
* 확장자 없는 모드가 보안 검증 우회 통로가 되면 안 된다.
*/
public function test_쿼리_형태에도_경로_탈출_방어가_적용된다(): void
{
$response = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode('../../../.env'));
$this->assertNotSame(200, $response->getStatusCode(), '경로 탈출이 차단되지 않았다');
}
/**
* 허용되지 않은 확장자는 `?file=` 형태에서도 거부되어야 한다.
*/
public function test_쿼리_형태에도_확장자_화이트리스트가_적용된다(): void
{
$response = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode('config.php'));
$this->assertNotSame(200, $response->getStatusCode(), '허용되지 않은 확장자가 서빙되었다');
}
/**
* 관리자 편집기 엔드포인트는 두 형태 모두 권한 가드를 받아야 한다.
*
* 확장자 없는 형태에 미들웨어가 빠지면 무인증 우회 통로가 열린다.
*/
public function test_편집기_엔드포인트가_두_형태_모두_무인증을_거부한다(): void
{
$uris = [
'/api/admin/templates/sirsoft-basic/editor/components.json',
'/api/admin/templates/sirsoft-basic/editor/components',
'/api/admin/templates/sirsoft-basic/editor/component-styles.css',
'/api/admin/templates/sirsoft-basic/editor/component-styles',
];
foreach ($uris as $uri) {
$status = $this->getJson($uri)->getStatusCode();
$this->assertContains(
$status,
[401, 403],
"{$uri} 가 무인증 요청을 거부하지 않았다 (상태코드 {$status})"
);
}
}
/**
* 권한을 가진 관리자는 두 형태 모두에서 동일한 응답을 받아야 한다.
*/
public function test_편집기_엔드포인트가_두_형태에서_동일한_응답을_반환한다(): void
{
$token = $this->createEditorAdminToken();
$viaExtension = $this->withHeaders(['Authorization' => "Bearer {$token}"])
->getJson('/api/admin/templates/sirsoft-basic/editor/components.json');
$viaExtensionless = $this->withHeaders(['Authorization' => "Bearer {$token}"])
->getJson('/api/admin/templates/sirsoft-basic/editor/components');
$this->assertSame(
$viaExtension->getStatusCode(),
$viaExtensionless->getStatusCode(),
'두 형태의 상태코드가 다르다'
);
$this->assertSame(
$viaExtension->getContent(),
$viaExtensionless->getContent(),
'두 형태의 응답 본문이 다르다'
);
}
/**
* 자산 서빙을 위해 활성 상태의 템플릿 레코드를 만듭니다.
*
* @param string $identifier 템플릿 식별자
*/
private function activateTemplate(string $identifier): void
{
Template::updateOrCreate(['identifier' => $identifier], [
'vendor' => 'sirsoft',
'name' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'],
]);
}
/**
* 레이아웃 편집 권한을 가진 관리자 토큰을 생성합니다.
*
* @return string Sanctum 평문 토큰
*/
private function createEditorAdminToken(): string
{
$permission = Permission::firstOrCreate([
'identifier' => 'core.templates.layouts.edit',
], [
'name' => '레이아웃 편집',
'display_name' => '레이아웃 편집',
'type' => 'admin',
]);
$role = Role::firstOrCreate(['identifier' => 'super-admin'], [
'name' => 'Super Admin',
'display_name' => 'Super Admin',
'is_default' => false,
]);
$role->permissions()->syncWithoutDetaching([$permission->id]);
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
return $user->createToken('admin')->plainTextToken;
}
}
@@ -0,0 +1,230 @@
/**
* 자산 URL 이중 모드 — 브라우저 자가 복구 및 루프 방지 불변식 (이슈 #486 단위 D)
*
* ## 무엇을 모사하는가
*
* nginx 의 정적 최적화 블록을 route intercept 로 모사한다.
*
* ```nginx
* location ~* \.(js|css|json)$ { expires max; access_log off; }
* ```
*
* 이 블록은 정규식 location 이라 프리픽스 location 보다 먼저 매칭되고, 내부에 PHP
* 핸들러가 없으면 `try_files ... /index.php` 폴백이 실행될 기회 없이 nginx 가 직접
* 파일시스템을 열려 시도해 404 가 된다. 즉 **경로가 정적 확장자로 끝나는 `/api/` 요청만**
* 404 가 되고, 확장자 없는 형태는 정상 통과한다 — 아래 intercept 가 그 조건을 그대로 쓴다.
*
* ## 검증하는 불변식 (계획서 §12)
*
* | # | 불변식 | 이 spec 의 단언 |
* |---|---|---|
* | L1 | 전환은 단방향 1회 | 전환 후 확장자 형태 재요청 0회 |
* | L2 | 기존 재시도 예산 안에서 소비 | 자산당 네트워크 시도 ≤ 3, 페이지당 ≤ 9 |
* | L3 | 자동 location.reload() 금지 | 네비게이션 발화 0회 |
* | L4 | 모든 실패 경로는 폴백 UI 로 수렴 | 양쪽 실패 시 폴백 UI 노출 |
* | L8 | pending 카운터 불변식 | 초기화가 정확히 1회 |
*
* 계획서 §"루프 방지 전용 테스트" 는 이 spec 이 **역방향 폴백을 일부러 넣은 브랜치에서
* 무한 루프로 터지는지** 확인해야 가드로 인정된다고 명시한다.
*/
import { test, expect } from '@playwright/test';
/** 정적 확장자로 끝나는 `/api/` 경로 — nginx 정규식 location 의 매칭 조건과 동일 */
const STATIC_EXT_API = /\/api\/.*\.(js|css|json)(\?|$)/;
/**
* 페이지에 "정적 최적화 nginx" 를 설치한다.
*
* 경로가 정적 확장자로 끝나는 `/api/` 요청만 404 로 가로챈다. 쿼리스트링은 보지 않는다 —
* nginx 의 location 정규식도 쿼리를 제외한 경로에만 매칭되기 때문이며, 이것이
* `?file=` 형태가 안전한 근거다.
*
* @param page 대상 페이지
* @param requests 가로챈 URL 이 누적될 배열
*/
async function installStaticOptimizationServer(page: import('@playwright/test').Page, requests: string[]) {
await page.route('**/api/**', async (route) => {
const url = route.request().url();
const path = new URL(url).pathname;
if (/\.(js|css|json)$/i.test(path)) {
requests.push(url);
await route.fulfill({ status: 404, contentType: 'text/html', body: 'Not Found' });
return;
}
await route.continue();
});
}
test.describe('자산 URL 이중 모드 자가 복구', () => {
test.beforeEach(async ({ page }) => {
// 이전 방문 캐시가 남아 있으면 첫 요청부터 확장자 없는 형태를 써서
// "전환이 일어나는지" 를 관측할 수 없다.
await page.addInitScript(() => {
try {
window.localStorage.clear();
} catch (e) { /* noop */ }
});
});
test('정적 최적화 서버에서 설정 변경 없이 화면이 뜬다 (자가 복구)', async ({ page }) => {
const blocked: string[] = [];
await installStaticOptimizationServer(page, blocked);
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 재시도 백오프(300+600ms) + 폴백 렌더까지 지난 뒤에 판정한다.
// domcontentloaded 직후에 단언하면 폴백이 아직 그려지기 전이라
// 자가 복구가 실패한 경우에도 통과하는 거짓 green 이 된다.
await page.waitForTimeout(3_000);
// 확장자 형태가 실제로 가로채였는지 (모사가 유효한지) 먼저 확인
expect(blocked.length, '정적 확장자 API 요청이 하나도 가로채이지 않았다 — 모사가 무효').toBeGreaterThan(0);
// 자가 복구가 모드를 전환했어야 한다
const mode = await page.evaluate(() => (window as any).__g7AssetUrlMode ?? (window as any).G7Config?.assetUrlMode);
expect(mode, '자가 복구가 확장자 없는 모드로 전환하지 않았다').toBe('extensionless');
// 폴백 UI 가 아니라 실제 앱이 렌더되어야 한다
const bootstrapFailed = await page.evaluate(() => (window as any).__g7Bootstrap?.failed);
expect(bootstrapFailed, '자가 복구에 실패해 폴백 UI 로 떨어졌다').toBeFalsy();
const app = page.locator('#app');
await expect(app).toBeAttached();
await expect(app.getByText(/화면을 불러오지 못했습니다|Failed to load/i)).toHaveCount(0);
});
test('L1 — 전환 이후 확장자 형태를 다시 요청하지 않는다', async ({ page }) => {
const blocked: string[] = [];
await installStaticOptimizationServer(page, blocked);
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 전환이 확정된 시점을 기준으로 삼는다
await page.waitForFunction(
() => (window as any).__g7AssetUrlMode === 'extensionless',
{ timeout: 15_000 },
);
const beforeCount = blocked.length;
// 전환이 확정된 뒤 추가로 시간을 줘도 확장자 형태 재요청이 없어야 한다
await page.waitForTimeout(3_000);
const afterSwitch = blocked.slice(beforeCount);
expect(
afterSwitch,
`전환 이후 확장자 형태를 재요청했다 (역방향 복귀 의심):\n${afterSwitch.join('\n')}`,
).toEqual([]);
});
test('L2·L4 — 양쪽 형태가 모두 실패하면 3시도에서 멈추고 폴백 UI 로 끝난다', async ({ page }) => {
// PHP 다운 모사 — 확장자 유무와 무관하게 모든 /api/ 를 404
const attempts: string[] = [];
await page.route('**/api/**', async (route) => {
attempts.push(route.request().url());
await route.fulfill({ status: 404, contentType: 'text/html', body: 'Not Found' });
});
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 재시도 백오프(300 + 600ms)와 폴백 렌더까지 충분히 기다린다
await page.waitForTimeout(5_000);
// 자산별 시도 횟수 — 경로(쿼리 제외) 기준으로 묶는다
const perAsset = new Map<string, number>();
for (const url of attempts) {
const key = new URL(url).pathname.replace(/\.(js|css|json)$/i, '');
perAsset.set(key, (perAsset.get(key) ?? 0) + 1);
}
for (const [asset, count] of perAsset) {
expect(count, `${asset} 의 네트워크 시도가 3회를 넘었다 (요청 증폭)`).toBeLessThanOrEqual(3);
}
// 부트스트랩 자산은 CSS 1 + JS 2 → 페이지당 상한 9
expect(attempts.length, '페이지당 총 시도가 상한 9를 넘었다').toBeLessThanOrEqual(9);
// L4 — 최종 상태는 폴백 UI
await expect(page.locator('#app')).not.toBeEmpty();
});
test('L3 — 어떤 경로에서도 스크립트가 페이지를 새로고침하지 않는다', async ({ page }) => {
let navigations = 0;
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) navigations += 1;
});
await page.route('**/api/**', async (route) => {
await route.fulfill({ status: 404, contentType: 'text/html', body: 'Not Found' });
});
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const afterGoto = navigations;
// 10초 관찰 — 보정이 효과 없을 때 영구 리로드 루프가 도는지
await page.waitForTimeout(10_000);
expect(
navigations - afterGoto,
`goto 이후 네비게이션이 ${navigations - afterGoto}회 발생했다 (자동 리로드 루프 의심)`,
).toBe(0);
});
test('L8 — 전환 성공 시 초기화가 정확히 1회만 일어난다', async ({ page }) => {
const blocked: string[] = [];
await installStaticOptimizationServer(page, blocked);
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.waitForTimeout(2_000);
// 부트스트랩이 노출하는 관측 가능한 상태로 검증한다.
// initTemplateApp 을 래핑해 호출 횟수를 세는 방식은 쓰지 않는다 — 코어 번들이
// `G7Core` 를 할당한 뒤 메서드를 덧붙이는 2단계 구성이라 할당 시점 래핑이
// 함수를 잡지 못하고 항상 0 이 나온다(계측 실패를 위반으로 오판하게 된다).
const state = await page.evaluate(() => ({
pending: (window as any).__g7Bootstrap?.pending,
initialized: (window as any).__g7Bootstrap?.initialized,
failed: (window as any).__g7Bootstrap?.failed,
}));
expect(state.pending, `pending 카운터가 0 에 도달하지 않았다 (실제: ${state.pending})`).toBe(0);
expect(state.initialized, '초기화가 완료되지 않았다').toBe(true);
expect(state.failed, '자가 복구에 성공했는데 failed 로 표시됐다').toBeFalsy();
// tryInit 은 initialized 플래그로 1회 게이트되므로 위 상태가 곧 "정확히 1회" 의 증거다.
//
// `#app` 직계 자식 수는 중복 마운트 신호로 쓰지 않는다 — 정상 렌더에서도
// 페이지 본문 + 쿠키 배너처럼 형제 컴포넌트가 여럿 붙어 2 이상이 된다.
// 대신 부트스트랩 자산이 중복 실행되지 않았는지를 스크립트 태그 수로 본다.
const duplicateScripts = await page.evaluate(() => {
const srcs = Array.from(document.querySelectorAll('script[src]'))
.map((el) => (el as HTMLScriptElement).src)
.filter((src) => src.includes('components.iife.js') || src.includes('file=js%2Fcomponents'));
return srcs.length - new Set(srcs).size;
});
expect(duplicateScripts, '동일 컴포넌트 번들 스크립트가 중복 삽입됐다').toBe(0);
});
test('확장자 없는 형태로 실제 자산이 서빙된다', async ({ page }) => {
// 서버 계약 확인 — ?file= 형태가 200 을 주는지
const response = await page.request.get('/api/system/asset-probe');
expect(response.status()).toBe(200);
expect(await response.text()).toContain('G7_ASSET_PROBE_OK');
// 확장자 형태 프로브도 정상 환경에서는 200
const withExt = await page.request.get('/api/system/asset-probe.js');
expect(withExt.status()).toBe(200);
});
});
@@ -0,0 +1,88 @@
<?php
namespace Tests\Unit\Services;
use App\Services\TemplateService;
use ReflectionMethod;
use Tests\TestCase;
/**
* TemplateService::sanitizePath() 경로 정제 회귀 테스트 (이슈 #486 인접 결함 ③).
*
* 결함: `str_replace(['../', '..\\'], '', $path)` 1회성 치환은 제거 자체가 새 패턴을
* 만들어낸다. `....//` 는 가운데 `../` 가 제거되면서 `../` 로 복원된다.
*
* FormRequest 의 realpath 검사가 앞단에서 막고 있어 현재 악용은 불가하지만,
* 다층 방어의 한 계층이 무력한 상태였으므로 교정한다.
*/
class TemplateServiceSanitizePathTest extends TestCase
{
/**
* sanitizePath() 를 리플렉션으로 호출합니다.
*
* @param string $path 정제할 경로
* @return string 정제 결과
*/
private function sanitize(string $path): string
{
$method = new ReflectionMethod(TemplateService::class, 'sanitizePath');
$method->setAccessible(true);
return $method->invoke(app(TemplateService::class), $path);
}
/**
* 중첩 패턴이 탈출 시퀀스를 복원하지 않아야 한다.
*
* 수정 전에는 `....//` → `../` 로 복원되어 이 단언이 실패한다.
*/
public function test_중첩_패턴이_상위_경로_시퀀스를_복원하지_않는다(): void
{
$payloads = [
'....//',
'....//....//etc/passwd',
'....\\\\',
'..../\\',
'....//...././/config.json',
];
foreach ($payloads as $payload) {
$result = $this->sanitize($payload);
$this->assertStringNotContainsString('../', $result, "상위 경로 시퀀스 잔존: {$payload} → {$result}");
$this->assertStringNotContainsString('..\\', $result, "상위 경로 시퀀스 잔존: {$payload} → {$result}");
}
}
/**
* 단순 상위 경로 패턴은 기존대로 제거되어야 한다.
*/
public function test_단순_상위_경로_패턴을_제거한다(): void
{
$this->assertStringNotContainsString('../', $this->sanitize('../../.env'));
$this->assertStringNotContainsString('..\\', $this->sanitize('..\\..\\.env'));
}
/**
* 절대 경로 선행 구분자를 제거해야 한다.
*/
public function test_절대_경로_선행_구분자를_제거한다(): void
{
$this->assertSame('js/a.js', $this->sanitize('/js/a.js'));
$this->assertSame('js/a.js', $this->sanitize('\\js/a.js'));
}
/**
* 정상 경로는 변형하지 않아야 한다.
*
* 과잉 정제로 멀쩡한 자산 경로가 깨지면 안 된다.
*/
public function test_정상_경로는_변형하지_않는다(): void
{
$this->assertSame('js/components.iife.js', $this->sanitize('js/components.iife.js'));
$this->assertSame('css/components.css', $this->sanitize('css/components.css'));
$this->assertSame('fonts/a.woff2', $this->sanitize('fonts/a.woff2'));
// 파일명에 포함된 점 두 개는 경로 탈출이 아니므로 보존
$this->assertSame('js/a..b.js', $this->sanitize('js/a..b.js'));
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\Support;
use App\Support\AssetUrl;
use Tests\TestCase;
/**
* 자산 URL 빌더 가드 (이슈 #486 단위 B).
*
* 단위 B 의 완료 조건은 "기본값 `extension` 에서 렌더 결과가 단위 A 이전과 바이트 동일" 이다.
* 아래 확장자 모드 기대값은 치환 이전 소스에 하드코딩되어 있던 문자열을 그대로 옮긴 것으로,
* 빌더 도입이 URL 을 한 글자도 바꾸지 않았음을 고정한다.
*/
class AssetUrlTest extends TestCase
{
protected function tearDown(): void
{
AssetUrl::forceMode(null);
parent::tearDown();
}
/**
* 기본 모드는 확장자 유지여야 한다.
*
* 기본값이 뒤집히면 정상 환경 다수가 확장자 기반 캐시·gzip 최적화를 잃는다.
*/
public function test_기본_모드는_확장자_유지다(): void
{
AssetUrl::forceMode(null);
$this->assertSame(AssetUrl::MODE_EXTENSION, AssetUrl::mode());
$this->assertFalse(AssetUrl::isExtensionless());
}
/**
* 확장자 모드 출력이 치환 이전 하드코딩 문자열과 동일해야 한다.
*/
public function test_확장자_모드_출력이_기존_하드코딩과_바이트_동일하다(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
// resources/views/{app,admin}.blade.php
$this->assertSame(
'/api/templates/assets/sirsoft-basic/css/components.css?v=7',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css', 7)
);
$this->assertSame(
'/api/templates/assets/sirsoft-basic/js/components.iife.js?v=7',
AssetUrl::templateAsset('sirsoft-basic', 'js/components.iife.js', 7)
);
// TemplateComposer::buildExtensionBundleUrls
$this->assertSame('/api/modules/bundle.js?v=7', AssetUrl::extensionBundle('modules', 'js', 7));
$this->assertSame('/api/modules/bundle.css?v=7', AssetUrl::extensionBundle('modules', 'css', 7));
$this->assertSame('/api/plugins/bundle.js?v=7', AssetUrl::extensionBundle('plugins', 'js', 7));
$this->assertSame('/api/plugins/bundle.css?v=7', AssetUrl::extensionBundle('plugins', 'css', 7));
// TemplateComposer::collect{Module,Plugin}Assets — 모듈/플러그인은 dist/ 를 경로에 포함
$this->assertSame(
'/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js?v=7',
AssetUrl::moduleAsset('sirsoft-ecommerce', 'dist/js/module.iife.js', 7)
);
$this->assertSame(
'/api/plugins/assets/sirsoft-gdpr/dist/css/plugin.css?v=7',
AssetUrl::pluginAsset('sirsoft-gdpr', 'dist/css/plugin.css', 7)
);
// ModuleManager / PluginManager — 버전 미부착
$this->assertSame(
'/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js',
AssetUrl::moduleAsset('sirsoft-ecommerce', 'dist/js/module.iife.js')
);
// SeoRenderer — dist/ 를 벗긴 경로 + 버전 없음
$this->assertSame(
'/api/templates/assets/sirsoft-basic/css/components.css',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css')
);
// AdminTemplateAssetController — 편집기 CSS
$this->assertSame(
'/api/admin/templates/sirsoft-basic/editor/component-styles.css?v=7',
AssetUrl::suffixed('/api/admin/templates/sirsoft-basic/editor/component-styles', 'css', 7)
);
}
/**
* 확장자 없는 모드에서는 자산 경로가 `file` 쿼리로 옮겨져야 한다.
*/
public function test_확장자_없는_모드는_자산_경로를_쿼리로_옮긴다(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$this->assertSame(
'/api/templates/assets/sirsoft-basic?file=js%2Fcomponents.iife.js&v=7',
AssetUrl::templateAsset('sirsoft-basic', 'js/components.iife.js', 7)
);
$this->assertSame(
'/api/modules/assets/sirsoft-ecommerce?file=dist%2Fjs%2Fmodule.iife.js',
AssetUrl::moduleAsset('sirsoft-ecommerce', 'dist/js/module.iife.js')
);
}
/**
* 확장자 없는 모드에서는 번들 접미사가 경로 세그먼트로 내려가야 한다.
*
* 접미사를 제거하면 js/css 가 둘 다 `bundle` 이 되어 구분 불가.
*/
public function test_확장자_없는_모드는_번들_접미사를_세그먼트로_내린다(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$this->assertSame('/api/modules/bundle/js?v=7', AssetUrl::extensionBundle('modules', 'js', 7));
$this->assertSame('/api/plugins/bundle/css?v=7', AssetUrl::extensionBundle('plugins', 'css', 7));
}
/**
* 확장자 없는 모드에서는 고정 접미사가 제거되어야 한다.
*/
public function test_확장자_없는_모드는_고정_접미사를_제거한다(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$this->assertSame(
'/api/templates/sirsoft-basic/routes',
AssetUrl::suffixed('/api/templates/sirsoft-basic/routes', 'json')
);
$this->assertSame(
'/api/admin/templates/sirsoft-basic/editor/component-styles?v=7',
AssetUrl::suffixed('/api/admin/templates/sirsoft-basic/editor/component-styles', 'css', 7)
);
}
/**
* 생성된 URL 의 경로 부분에는 어떤 모드에서도 정적 확장자가 남지 않아야 한다.
*
* 이것이 이번 이슈의 본질 — 경로에 확장자가 남으면 nginx 정적 블록이 그대로 가로챈다.
*/
public function test_확장자_없는_모드_주소_경로에_정적_확장자가_없다(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$urls = [
AssetUrl::templateAsset('t', 'js/a.js', 7),
AssetUrl::moduleAsset('m', 'dist/js/a.js', 7),
AssetUrl::pluginAsset('p', 'dist/css/a.css'),
AssetUrl::extensionBundle('modules', 'js', 7),
AssetUrl::extensionBundle('plugins', 'css'),
AssetUrl::suffixed('/api/templates/t/routes', 'json'),
];
foreach ($urls as $url) {
$path = parse_url($url, PHP_URL_PATH);
$this->assertDoesNotMatchRegularExpression(
'/\.(js|mjs|css|json|map)$/i',
(string) $path,
"확장자 없는 모드인데 경로에 정적 확장자가 남아있음: {$url}"
);
}
}
/**
* 설정 조회가 실패해도 기본 모드로 폴백해야 한다.
*
* 이 값은 blade 렌더 경로에서 읽히므로 여기서 예외가 나면 화면 전체가 죽는다.
*/
public function test_설정_조회_실패시_기본_모드로_폴백한다(): void
{
AssetUrl::forceMode(null);
$this->assertSame(AssetUrl::MODE_EXTENSION, AssetUrl::mode());
}
}
@@ -0,0 +1,215 @@
<?php
namespace Tests\Unit\Support\Routing;
use App\Support\Routing\DualRouteProxy;
use Illuminate\Http\Request;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
/**
* 자산 URL 이중 모드 라우트 등록 가드 (이슈 #486 단위 A).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서 동적 응답이
* nginx 에 가로채이지 않도록, 모든 확장자 붙은 동적 엔드포인트는 확장자 없는
* 형태를 함께 제공해야 한다. 한쪽이 조용히 누락되면 그 화면만 죽으므로
* "전수 등록" 자체를 테스트로 못박는다.
*/
class DualExtensionRouteTest extends TestCase
{
/**
* 이중 등록이 요구되는 전체 라우트 이름 (계획서 #486 §1 의 23개 + 프로브).
*
* 신규 확장자 엔드포인트를 추가하면 이 목록에도 추가되어야 한다.
*
* 데이터 프로바이더 대신 평면 목록으로 두고 각 테스트가 내부에서 순회한다.
* 프로바이더로 케이스를 분리하면 케이스마다 애플리케이션이 부팅되어
* DB 커넥션이 케이스 수만큼 열리고, 스위트 동시 실행 시 커넥션이 고갈된다.
*
* @return array<int, string>
*/
private static function dualRouteNames(): array
{
return [
// Public — 템플릿
'api.public.templates.routes',
'api.public.templates.config',
'api.public.templates.assets',
'api.public.templates.components',
'api.public.templates.language',
// Public — 레이아웃
'api.public.layouts.preview.serve',
'api.public.layouts.serve',
// Public — 모듈
'api.public.modules.bundle.js',
'api.public.modules.bundle.css',
'api.public.modules.assets',
'api.public.modules.components',
// Public — 플러그인
'api.public.plugins.bundle.js',
'api.public.plugins.bundle.css',
'api.public.plugins.assets',
'api.public.plugins.components',
// Public — 감지 프로브
'api.public.system.asset-probe',
// Admin — 레이아웃 편집기
'api.admin.templates.editor-components',
'api.admin.templates.editor-routes',
'api.admin.templates.editor-spec',
'api.admin.templates.editor-lang',
'api.admin.templates.editor-permission-candidates',
'api.admin.templates.editor-css',
'api.admin.templates.editor-seo-candidates',
'api.admin.templates.editor-broadcast-catalog',
];
}
/**
* 모든 대상 엔드포인트가 두 형태로 등록되어 있어야 한다.
*/
public function test_엔드포인트가_확장자_형태와_확장자_없는_형태로_모두_등록된다(): void
{
foreach (self::dualRouteNames() as $name) {
$extension = Route::getRoutes()->getByName($name);
$extensionless = Route::getRoutes()->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX);
$this->assertNotNull($extension, "확장자 형태 라우트 미등록: {$name}");
$this->assertNotNull(
$extensionless,
"확장자 없는 형태 라우트 미등록: {$name} — 정적 최적화 블록이 있는 서버에서 이 엔드포인트가 죽는다"
);
}
}
/**
* 두 형태는 동일한 컨트롤러 액션과 미들웨어로 들어가야 한다.
*
* 한쪽에만 permission 미들웨어가 붙으면 확장자 없는 형태가 권한 우회 통로가 된다.
*/
public function test_두_형태의_액션과_미들웨어가_동일하다(): void
{
foreach (self::dualRouteNames() as $name) {
$extension = Route::getRoutes()->getByName($name);
$extensionless = Route::getRoutes()->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX);
$this->assertSame(
$extension->getActionName(),
$extensionless->getActionName(),
"두 형태의 컨트롤러 액션 불일치: {$name}"
);
$this->assertSame(
$extension->gatherMiddleware(),
$extensionless->gatherMiddleware(),
"두 형태의 미들웨어 불일치: {$name} — 확장자 없는 형태가 권한 가드를 우회할 수 있다"
);
}
}
/**
* 확장자 형태 URI 는 실제로 해당 확장자로 끝나야 한다.
*/
public function test_확장자_형태_주소가_정적_확장자로_끝난다(): void
{
$suffixed = [
'api.public.templates.routes' => '.json',
'api.public.layouts.serve' => '.json',
'api.public.modules.bundle.js' => '.js',
'api.public.plugins.bundle.css' => '.css',
'api.admin.templates.editor-css' => '.css',
'api.public.system.asset-probe' => '.js',
];
foreach ($suffixed as $name => $suffix) {
$uri = Route::getRoutes()->getByName($name)->uri();
$this->assertStringEndsWith($suffix, $uri, "확장자 형태 URI 가 {$suffix} 로 끝나지 않음: {$name}");
}
}
/**
* 확장자 없는 형태 URI 의 마지막 세그먼트에는 정적 확장자가 남아있지 않아야 한다.
*
* 이것이 이번 이슈의 본질이다 — 확장자가 남아있으면 nginx 정적 블록이 그대로 가로챈다.
*/
public function test_확장자_없는_형태_주소에_정적_확장자가_없다(): void
{
foreach (self::dualRouteNames() as $name) {
$uri = Route::getRoutes()
->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX)
->uri();
$lastSegment = basename($uri);
$this->assertDoesNotMatchRegularExpression(
'/\.(js|mjs|css|json|map|png|jpe?g|svg|webp|gif|ico|woff2?|ttf|otf|eot)$/i',
$lastSegment,
"확장자 없는 형태에 정적 확장자가 남아있음: {$name} → {$uri}"
);
}
}
/**
* `.json` 요청은 확장자 형태 라우트로 매칭되어야 한다 (등록 순서 가드).
*
* `layouts/{templateIdentifier}/{layoutName}` 의 layoutName 정규식은 `.` 를 포함해
* greedy 하므로, 확장자 없는 형태가 먼저 등록되면 `.json` 요청까지 삼킨다.
* 매크로가 확장자 형태를 먼저 등록한다는 불변식을 실제 매칭으로 검증한다.
*/
public function test_json_요청이_확장자_없는_라우트에_삼켜지지_않는다(): void
{
$cases = [
'/api/layouts/sirsoft-basic/home.json' => 'api.public.layouts.serve',
'/api/templates/sirsoft-basic/routes.json' => 'api.public.templates.routes',
'/api/templates/sirsoft-basic/components.json' => 'api.public.templates.components',
'/api/modules/bundle.js' => 'api.public.modules.bundle.js',
];
foreach ($cases as $uri => $expectedName) {
$matched = $this->matchRouteName('GET', $uri);
$this->assertSame(
$expectedName,
$matched,
"{$uri} 가 확장자 형태가 아닌 다른 라우트에 매칭됨 (등록 순서 역전 의심)"
);
}
}
/**
* 확장자 없는 요청은 확장자 없는 라우트로 매칭되어야 한다.
*/
public function test_확장자_없는_요청이_확장자_없는_라우트로_매칭된다(): void
{
$cases = [
'/api/layouts/sirsoft-basic/home' => 'api.public.layouts.serve.extensionless',
'/api/layouts/preview/'.str_repeat('a', 8).'-aaaa-aaaa-aaaa-'.str_repeat('a', 12) => 'api.public.layouts.preview.serve.extensionless',
'/api/templates/sirsoft-basic/routes' => 'api.public.templates.routes.extensionless',
'/api/modules/bundle/js' => 'api.public.modules.bundle.js.extensionless',
'/api/plugins/bundle/css' => 'api.public.plugins.bundle.css.extensionless',
'/api/templates/assets/sirsoft-basic' => 'api.public.templates.assets.extensionless',
'/api/system/asset-probe' => 'api.public.system.asset-probe.extensionless',
];
foreach ($cases as $uri => $expectedName) {
$this->assertSame($expectedName, $this->matchRouteName('GET', $uri), "매칭 불일치: {$uri}");
}
}
/**
* 주어진 요청에 매칭되는 라우트의 이름을 반환합니다.
*
* @param string $method HTTP 메서드
* @param string $uri 요청 URI
* @return string|null 매칭된 라우트 이름 (미매칭 시 null)
*/
private function matchRouteName(string $method, string $uri): ?string
{
$request = Request::create($uri, $method);
/** @var RoutingRoute $route */
$route = Route::getRoutes()->match($request);
return $route->getName();
}
}