diff --git a/AGENTS.md b/AGENTS.md
index c7ab3daa..49a6d729 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -513,6 +513,23 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
+### 공개 자산 직접 URL 은 운영자 선언으로만 열린다
+
+저장된 파일의 주소를 직접 URL(CDN)로 내보내면 서버 스트리밍 경로가 통째로 건너뛰어진다. 그 경로에 게이트가 있으면 게이트가 사라지고, 대상 저장소가 실제로는 비공개면 발급된 주소가 403 이 된다. 둘 다 예외도 로그도 남기지 않는다 — 그 이미지들만 조용히 깨지거나, 막아야 할 파일이 조용히 열린다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+|--------|---------------|
+| `filesystems.disks.{disk}.url` 이 설정돼 있다는 이유로 그 디스크를 공개로 간주해 직접 URL 발급 | 운영자가 명시 선언한 `core.storage.public_asset_disk` 와 **행 disk 가 일치할 때만** 직접 URL. `url` 설정 유무는 "URL 을 만들 수 있는가" 이지 "익명 읽기가 되는가" 가 아니다 |
+| 권한·게이트(비밀글·소유권·발행 상태·본인인증·다운로드 카운트)가 걸린 첨부 경로를 직접 URL 로 전환 | 게이트가 없는 완전 공개 자산 카테고리에만 배선. 게이트가 있는 경로의 응답 `url` 칸은 그 게이트를 통과하는 서빙 URL 로 채운다 |
+| 공개 판정을 `PublicAssetDisk::resolve()` 밖에서 사본으로 재작성 | 판정은 그 단일 지점 경유. `''`·`'none'`·config 에 없는 고아 디스크가 등가 비교만으로 통과하는 것을 막는다 |
+| 행 disk 로 `withDisk()` 를 무검증 호출 | 존재 확인 후 폴백 — 공개 자산 디스크가 플러그인 등록 디스크일 수 있고, 그 플러그인 비활성화 시 무인증 공개 서빙 라우트가 **500** 이 된다 |
+| 직접 URL 로 전환하면서 "행 삭제 = 접근 차단" 전제를 그대로 둠 | 직접 URL 은 행과 무관하게 파일을 가리킨다 — 회수 수단(파일 삭제)과 그 한계를 문서에 남긴다 |
+
+이 결함군의 유일한 증상은 화면이다: 직접 URL 이 403 을 돌려주면 그 이미지들만 깨지고, 게이트가 우회되면 정상 200 이 나간다. 서버 로그에는 어느 쪽도 흔적이 없다.
+
+> 상세: [storage-driver.md](docs/extension/storage-driver.md) "공개 자산 전용 디스크 분리"
+> 이 규칙은 정적 검사로 판정할 수 없다 — "이 경로에 게이트가 있는가"·"행 disk 와 설정이 같은 축인가" 는 의미 판정이므로, 회귀 테스트(특히 비공개 S3 행 + 공개 URL 설정 조합에서 서빙 라우트가 나오는 케이스)가 잠근다
+
### 직접 전송로는 자격증명을 스스로 싣는다
레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5a3cdd0..56625848 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
- 설치 중 Composer 단계가 실패했을 때 안내하는 수동 명령도 운영용 구성(`--no-dev`)으로 바뀌었습니다. 종전 안내를 그대로 따르면 개발용 패키지가 섞인 상태로 설치되어 이후 코어 업데이트가 중단될 수 있었습니다.
- 코어 업데이트가 운영 `vendor/` 에 개발용 패키지가 있는지 확인해 로그에 남깁니다. 의존성 변경이 없어 재설치를 건너뛰는 경우에는 개발용 패키지가 그대로 남는다는 경고와 정리 명령을 함께 안내합니다.
- 코어 업데이트가 끝나면 실행 중인 큐 워커에 재시작 신호를 보내, 워커를 손수 재시작하지 않아도 새 코드가 반영됩니다.
+- 「공개 자산 스토리지」를 지정하면 레이아웃 배경 이미지가 그 저장소에 저장됩니다. 이미 올라간 파일은 옮기지 않으며 각자 저장된 위치에서 그대로 서비스됩니다. 기존 첨부 저장소와 공개 자산 저장소를 같은 곳으로 지정한 경우에는 설정을 켜는 순간 이전에 올린 이미지도 저장소 주소로 바뀝니다. 자세한 사항은 관리자 > 환경설정 > 드라이버의 안내를 참고하세요.
### Fixed
@@ -37,6 +38,8 @@
- `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다.
- 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다.
- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다.
+- 레이아웃 편집기에서 올린 배경 이미지의 주소가 「공개 자산 스토리지」 설정과 무관하게 항상 사이트 서버를 거쳐 전달되던 문제를 수정했습니다. 파일은 설정한 저장소에 정상 저장되지만 방문자 요청은 매번 사이트 서버가 저장소에서 받아 다시 내보내고 있어, CDN 을 쓰도록 설정해도 그 효과를 볼 수 없었습니다. 이제 「공개 자산 스토리지」를 지정하면 그 저장소에 올라간 배경 이미지가 저장소 주소로 직접 전달됩니다. 설정하지 않은 경우와 설정 이전에 올린 이미지는 종전과 똑같이 동작합니다. (#134 @lyg-kaban 님께서 제보해주셨습니다.)
+- 확장이 카테고리별로 다른 저장소를 쓰도록 설정했을 때, 확장 개발용 파일 주소·경로 조회가 그 설정을 반영하지 않고 언제나 기본 저장소를 보던 문제를 수정했습니다.
## [7.0.10] - 2026-09-06
diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php
index acba99ab..33d786af 100644
--- a/app/Extension/AbstractModule.php
+++ b/app/Extension/AbstractModule.php
@@ -10,6 +10,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
+use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -1418,15 +1419,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
- $disk = ($override !== null && $override !== '')
- ? $override
- : (string) config('core.storage.public_asset_disk', '');
-
- if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
- return null;
- }
-
- return $disk;
+ return PublicAssetDisk::resolve($override);
}
/**
@@ -1464,26 +1457,30 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
+ * 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
+ * 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
+ *
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return string 전체 파일 시스템 경로
*/
public function getStorageBasePath(string $category): string
{
- return $this->getStorage()->getBasePath($category);
+ return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
- * public disk인 경우 직접 URL을 반환하고,
- * private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
+ * 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
+ * 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
+ * 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
- * @return string|null 파일 URL (private disk인 경우 null)
+ * @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
- return $this->getStorage()->url($category, $path);
+ return $this->getStorageFor($category)->url($category, $path);
}
}
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index 236cc313..e11f3e09 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -10,6 +10,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
+use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -1313,15 +1314,7 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
- $disk = ($override !== null && $override !== '')
- ? $override
- : (string) config('core.storage.public_asset_disk', '');
-
- if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
- return null;
- }
-
- return $disk;
+ return PublicAssetDisk::resolve($override);
}
/**
@@ -1359,26 +1352,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
+ * 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
+ * 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
+ *
* @param string $category 카테고리 (settings, data, temp)
* @return string 전체 파일 시스템 경로
*/
public function getStorageBasePath(string $category): string
{
- return $this->getStorage()->getBasePath($category);
+ return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
- * public disk인 경우 직접 URL을 반환하고,
- * private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
+ * 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
+ * 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
+ * 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
- * @return string|null 파일 URL (private disk인 경우 null)
+ * @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
- return $this->getStorage()->url($category, $path);
+ return $this->getStorageFor($category)->url($category, $path);
}
}
diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php
index 5630f0bd..b3007812 100644
--- a/app/Services/TemplateLayoutAttachmentService.php
+++ b/app/Services/TemplateLayoutAttachmentService.php
@@ -8,6 +8,7 @@ use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\TemplateLayoutAttachment;
use App\Support\ImageResizer;
+use App\Support\PublicAssetDisk;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
@@ -64,7 +65,10 @@ class TemplateLayoutAttachmentService
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
$relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}";
- $disk = config('attachment.disk', 'attachments');
+ // 운영자가 공개 자산 디스크를 선언했으면 그쪽에 저장한다 (방문자 요청이 CDN 을 탄다).
+ // 미선언이면 기존 첨부 디스크 그대로. 행이 자기 disk 를 기록하므로 나중에 설정이
+ // 바뀌어도 각 행은 자기 저장 위치를 기억한다.
+ $disk = PublicAssetDisk::resolve() ?? config('attachment.disk', 'attachments');
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용).
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다.
@@ -138,8 +142,7 @@ class TemplateLayoutAttachmentService
public function delete(TemplateLayoutAttachment $attachment): bool
{
// 1. 스토리지 파일 실삭제 (명시적 — CASCADE 미의존)
- $this->storage
- ->withDisk($attachment->disk)
+ $this->storageForRow($attachment->disk)
->delete(self::STORAGE_CATEGORY, $attachment->path);
// 2. DB 행 삭제
@@ -149,16 +152,48 @@ class TemplateLayoutAttachmentService
/**
* 첨부 파일의 공개 접근 URL을 생성합니다.
*
- * 첨부 파일은 비공개 `attachments` 디스크에 저장되어 직접 공개 URL 이 없다
- * (`StorageInterface::url()` 은 public 디스크 전용). 발행된 배경 이미지는 일반
- * 사이트 방문자에게도 로드되어야 하므로, 인증 불필요한 공개 서빙 라우트
- * (`PublicTemplateController::serveFile`)의 URL 을 돌려준다. 라우트는 첨부 id 로
- * 키되며 서빙 시 첨부가 해당 템플릿 소속인지 검증한다.
+ * 운영자가 공개 자산 디스크(`core.storage.public_asset_disk`)를 선언했고 행 disk 가
+ * 그 값과 일치할 때만 직접 URL(CDN)을 돌려준다. 그 외에는 인증 불필요한 공개 서빙
+ * 라우트(`PublicTemplateController::serveFile`)의 URL 을 돌려주며, 라우트는 첨부 id 로
+ * 키되고 서빙 시 첨부가 해당 템플릿 소속인지 검증한다.
+ *
+ * 디스크의 `url` 설정 유무로 판정하지 않는다 — 그 설정은 "URL 문자열을 만들 수
+ * 있는가" 일 뿐 "익명 읽기가 되는가" 가 아니어서, 비공개 버킷에 공개 URL 이
+ * 설정된 조합에서는 발급된 직접 URL 이 403 이 된다.
+ *
+ * @param TemplateLayoutAttachment $attachment 첨부 파일
+ * @return string 직접 URL 또는 공개 서빙 URL
+ */
+ public function resolveUrl(TemplateLayoutAttachment $attachment): string
+ {
+ return $this->resolveDirectUrl($attachment) ?? $this->proxyUrl($attachment);
+ }
+
+ /**
+ * 행 disk 가 공개 자산 디스크일 때만 직접 URL 해석을 시도합니다.
+ *
+ * @param TemplateLayoutAttachment $attachment 첨부 파일
+ * @return string|null 직접 URL (대상이 아니거나 훅이 차단하면 null)
+ */
+ private function resolveDirectUrl(TemplateLayoutAttachment $attachment): ?string
+ {
+ if (! PublicAssetDisk::isCurrent($attachment->disk)) {
+ return null;
+ }
+
+ // 게이트를 통과한 disk 는 PublicAssetDisk::resolve() 가 존재를 이미 확인했다.
+ return $this->storage
+ ->withDisk($attachment->disk)
+ ->url(self::STORAGE_CATEGORY, $attachment->path);
+ }
+
+ /**
+ * 공개 서빙 라우트(프록시) URL 을 생성합니다.
*
* @param TemplateLayoutAttachment $attachment 첨부 파일
* @return string 공개 서빙 URL
*/
- public function resolveUrl(TemplateLayoutAttachment $attachment): string
+ private function proxyUrl(TemplateLayoutAttachment $attachment): string
{
$template = $attachment->template;
$identifier = $template?->identifier ?? (string) $attachment->template_id;
@@ -169,6 +204,26 @@ class TemplateLayoutAttachmentService
]);
}
+ /**
+ * 행에 기록된 disk 기준 스토리지를 반환합니다 (고아 disk 방어).
+ *
+ * 공개 자산 디스크는 플러그인이 등록한 디스크일 수 있고, 그 플러그인이 비활성화되면
+ * 해당 disk 가 config 에서 사라진다. 미등록 disk 로 withDisk 를 만들면 이후
+ * response/delete 가 InvalidArgumentException 을 던져 **무인증 공개 서빙 라우트가
+ * 500** 이 되므로, 주입 스토리지로 폴백해 404 로 끝나게 한다.
+ *
+ * @param string|null $disk 행의 disk 컬럼 값
+ * @return StorageInterface 행 disk 의 스토리지 (고아면 주입 스토리지)
+ */
+ private function storageForRow(?string $disk): StorageInterface
+ {
+ if ($disk === null || $disk === '' || config("filesystems.disks.{$disk}") === null) {
+ return $this->storage;
+ }
+
+ return $this->storage->withDisk($disk);
+ }
+
/**
* 서빙 가능한 첨부의 인라인 스트림 응답과 캐싱 메타를 돌려줍니다.
*
@@ -190,7 +245,7 @@ class TemplateLayoutAttachmentService
// 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행).
// 로컬 절대 경로 조립(getBasePath)은 S3 등 원격 디스크 행에서 성립하지 않는다 (#99).
- $response = $this->storage->withDisk($attachment->disk)->response(
+ $response = $this->storageForRow($attachment->disk)->response(
self::STORAGE_CATEGORY,
$attachment->path,
$attachment->original_name ?? basename($attachment->path),
diff --git a/app/Support/PublicAssetDisk.php b/app/Support/PublicAssetDisk.php
new file mode 100644
index 00000000..8193a679
--- /dev/null
+++ b/app/Support/PublicAssetDisk.php
@@ -0,0 +1,55 @@
+ 코어 전역 설정(core.storage.public_asset_disk).
+ * 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
+ * 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
+ *
+ * @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
+ * @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
+ */
+ public static function resolve(?string $override = null): ?string
+ {
+ $disk = ($override !== null && $override !== '')
+ ? $override
+ : (string) config('core.storage.public_asset_disk', '');
+
+ if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
+ return null;
+ }
+
+ return $disk;
+ }
+
+ /**
+ * 주어진 disk 가 "지금" 유효한 공개 자산 디스크인지 판정합니다.
+ *
+ * 직접 URL 발급 여부의 단일 판정점입니다. 반드시 resolve() 를 경유하므로
+ * ''/'none'/고아 디스크가 등가 비교만으로 통과하지 않습니다.
+ *
+ * @param string|null $disk 행에 기록된 disk 값
+ * @return bool 공개 자산 디스크와 일치하면 true
+ */
+ public static function isCurrent(?string $disk): bool
+ {
+ return $disk !== null && $disk !== '' && $disk === self::resolve();
+ }
+}
diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md
index e99804b0..77050411 100644
--- a/docs/backend/api/templates.md
+++ b/docs/backend/api/templates.md
@@ -94667,7 +94667,7 @@ _목록 응답: `data` 는 첨부 항목 배열입니다 (페이지네이션 없
| original_name | string | `hero-bg.png` | 업로드 당시 원본 파일명 |
| mime_type | string | `image/png` | 파일 MIME 타입 |
| size | integer | `204800` | 파일 크기 (바이트) |
-| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 첨부 파일 접근 URL |
+| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 첨부 파일 접근 URL. 기본은 공개 서빙 라우트(프록시)이고, 관리자 환경설정의 공개 자산 스토리지를 켠 뒤 그 디스크에 올라간 첨부만 직접 URL(CDN 절대 주소)로 내려온다 |
| created_at | string | `2026-07-14T10:00:00+09:00` | 업로드 일시 (ISO 8601) |
**응답 예시**
@@ -94748,7 +94748,7 @@ _단건 응답: `data` 객체의 필드._
| original_name | string | `hero-bg.png` | 업로드된 원본 파일명 |
| mime_type | string | `image/png` | 파일 MIME 타입 |
| size | integer | `204800` | 파일 크기 (바이트) |
-| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 사용) |
+| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 사용). 기본은 공개 서빙 라우트(프록시)이고, 공개 자산 스토리지를 켜 그 디스크에 저장된 경우에만 직접 URL(CDN 절대 주소)이다 |
**응답 예시**
@@ -94762,7 +94762,7 @@ _단건 응답: `data` 객체의 필드._
"original_name": "hero-bg.png",
"mime_type": "image/png",
"size": 204800,
- "url": "/storage/template-layout-attachments/sirsoft-basic/hero-bg.png"
+ "url": "/api/templates/sirsoft-basic/layout-attachments/1/file"
}
}
```
diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md
index 8e4ef2d8..6ad078c1 100644
--- a/docs/extension/storage-driver.md
+++ b/docs/extension/storage-driver.md
@@ -1581,8 +1581,9 @@ class AttachmentServiceTest extends TestCase
## 공개 자산 전용 디스크 분리 (직접 URL/CDN 서빙)
-완전 공개 자산(상품/카테고리/리뷰/에디터 이미지 등)을 S3+CDN 등 원격 디스크에서
-직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이 100% 보존됩니다.
+완전 공개 자산(상품/카테고리/리뷰/에디터 이미지, 레이아웃 배경 이미지 등)을 S3+CDN 등
+원격 디스크에서 직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이
+100% 보존됩니다.
### 설정 사슬
@@ -1608,6 +1609,13 @@ class AttachmentServiceTest extends TestCase
- `none` 은 스트리밍 유지(확장 개별 설정에서는 전역이 CDN 이어도 강제 스트리밍)입니다.
- 플러그인 비활성화로 디스크가 config 에서 사라지면(고아 디스크) 자동으로 스트리밍
폴백합니다 — 저장값은 보존되므로 재활성화 시 되살아납니다.
+- 코어의 **레이아웃 첨부**(레이아웃 편집기에서 올리는 배경 이미지)도 이 배선 대상입니다.
+ 이 카테고리의 서빙 라우트는 무인증 공개이고 검사가 템플릿 소속 확인 하나뿐이라
+ "완전 공개 자산" 에 해당합니다. 설정을 켠 뒤 업로드한 파일이 공개 자산 디스크에
+ 저장되고, 그 행의 disk 가 지금 설정된 공개 자산 디스크와 **일치할 때만** 직접 URL 이
+ 발급됩니다. 디스크에 공개 URL(`url`) 설정이 있다는 사실만으로는 직접 URL 을 발급하지
+ 않습니다 — 그 설정은 "URL 문자열을 만들 수 있는가" 일 뿐 "익명 읽기가 되는가" 가
+ 아니어서, 비공개 버킷에 공개 URL 을 적어 둔 구성에서는 발급된 주소가 403 이 됩니다.
### 혼재 운용
@@ -1632,6 +1640,15 @@ class AttachmentServiceTest extends TestCase
| 객체가 익명 읽기 가능 (버킷 정책 `s3:GetObject` 공개 또는 CDN 공개 배포) | 화면의 그 이미지들이 전부 깨짐 (S3 는 `403 AccessDenied` 를 XML 로 응답) |
| 공개 URL base(`S3 URL`)가 그 객체를 가리킴 | `url()` 이 null → 스트리밍 폴백(기능은 정상, CDN 이점만 없음) |
+### 켜기 전에 알아야 할 것
+
+| 항목 | 내용 |
+| --- | --- |
+| 이미 올라간 파일에도 소급 적용될 수 있다 | 기존 첨부 디스크와 공개 자산 디스크가 **같은 디스크**(예: 둘 다 S3)라면, 설정을 켜는 순간 켜기 이전에 올라간 첨부까지 직접 URL 로 바뀝니다. 행에 기록된 disk 값이 같아 신·구를 구분할 수단이 없기 때문입니다. 두 디스크를 다르게 두면(예: 첨부는 로컬, 공개 자산은 S3) 켠 뒤 올린 파일만 전환됩니다 |
+| 저장된 레이아웃에 주소가 그대로 남는다 | 설정을 켠 동안 배경 이미지를 지정해 저장하면 그 직접 URL 문자열이 레이아웃에 고정됩니다. 이후 설정을 끄거나 버킷을 비공개로 바꿔도 그 문자열은 자동으로 되돌아가지 않습니다(서버가 개입할 지점이 없습니다). 되돌리려면 편집기에서 이미지를 다시 선택해 저장해야 합니다 |
+| 첨부를 지워도 직접 URL 은 살아 있을 수 있다 | 직접 URL 은 첨부 기록과 무관하게 저장소의 파일을 가리킵니다. 스트리밍 방식에서는 기록을 지우면 그 주소가 곧바로 404 가 되었지만, 직접 URL 은 파일이 남아 있는 한 계속 열립니다. 접근을 확실히 끊으려면 저장소에서 파일 자체를 지워야 합니다 |
+| 확장 훅에 새로운 조합이 흘러간다 | `core.storage.filter_url` 훅을 `scope` 로 분기하는 확장은 이번부터 `scope='core'`, `identifier=null`, `category='template-layout-attachments'` 조합을 받습니다. 지금까지 코어에서 이 훅으로 URL 을 만드는 호출부가 없었으므로 이 조합은 처음 등장합니다 |
+
관리자 화면의 썸네일은 교차 출처 공개 URL 을 `
` 로 직접 사용하므로 CORS 설정은
필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
버킷/CDN 에 CORS 규칙이 필요합니다.
@@ -1651,6 +1668,13 @@ class AttachmentServiceTest extends TestCase
직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만
공개 자산 디스크를 적용합니다.
+**알려진 한계** — 상품 리뷰 이미지는 공개 자산으로 배선되어 있습니다. 리뷰를 노출에서
+숨김으로 바꾸면 목록·상세 응답에서 이미지가 사라지지만, 공개 자산 디스크를 켠 상태에서
+이미 발급된 직접 URL 을 알고 있는 사람은 그 주소로 파일에 계속 접근할 수 있습니다.
+직접 URL 은 저장소의 파일을 직접 가리키므로 응답에서 감추는 것만으로는 회수되지 않습니다.
+숨김을 접근 차단으로 쓰려면 그 카테고리에 공개 자산 디스크를 적용하지 않거나, 저장소에서
+파일 자체를 지워야 합니다.
+
---
## 관련 문서
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 987903dd..6af8a5c4 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -9,6 +9,7 @@
### Added
- 관리자 로그인 화면의 2단계 인증(인증번호 입력·다시 받기·처음부터·유효 시각)과 계정 잠금 해제 시각 안내의 일본어 번역을 추가했습니다.
+- 환경설정 > 드라이버 「공개 자산 스토리지」 설명에 추가된 문장(레이아웃 배경 이미지 포함, 켠 뒤 올린 파일만 직접 주소로 발급)의 일본어 번역을 갱신했습니다.
## [1.0.8] - 2026-09-06
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 465de995..35d33011 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -1697,7 +1697,7 @@
},
"public_asset": {
"title": "公開アセットストレージ",
- "desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。",
+ "desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像、レイアウト背景画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。有効化後にアップロードしたファイルのみ直接URLが発行され、そのアドレスは保存されたレイアウトにそのまま残ります。",
"disk": "ディスク",
"s3_help": "S3を選択する場合は、上のファイルストレージカードのS3 URL(CDNドメイン)設定が必要です。未設定の場合はストリーミングで動作します。"
},
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 7855cda1..9c1abf0a 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-09-08
+
+### Fixed
+
+- 첨부파일을 올린 뒤 돌려주는 응답의 파일 주소 칸이 늘 비어 있던 문제를 수정했습니다. 이 칸을 읽어 연동하던 외부 도구는 주소를 얻지 못했습니다. 이제 게시판의 첨부 다운로드 주소가 채워지며, 이 주소는 비밀글·삭제글 확인을 그대로 거치므로 볼 수 없는 첨부가 열리지는 않습니다.
+
## [1.1.1] - 2026-09-06
### Added
diff --git a/modules/_bundled/sirsoft-board/README.md b/modules/_bundled/sirsoft-board/README.md
index 6569eaec..f40d2592 100644
--- a/modules/_bundled/sirsoft-board/README.md
+++ b/modules/_bundled/sirsoft-board/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index f2c4f06a..e143c37f 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/composer.lock b/modules/_bundled/sirsoft-board/composer.lock
index 817a71f8..f5bee8e6 100644
--- a/modules/_bundled/sirsoft-board/composer.lock
+++ b/modules/_bundled/sirsoft-board/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f3ccc886d4904110ff4788fd5bc9e15d",
+ "content-hash": "dd055a1a5b6c59b9e30d2eaeb74b3416",
"packages": [],
"packages-dev": [],
"aliases": [],
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 2385af7c..54449f3a 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index fa177ce9..53b07fe9 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 3ac608c6..da338bba 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
index 9e335886..5a816d90 100644
--- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
@@ -792,6 +792,10 @@ class AttachmentService
/**
* 첨부파일 URL 조회
*
+ * 게시판 첨부는 비밀글·삭제글 게이트가 걸린 서빙 경로를 통해서만 내보낸다.
+ * 직접 URL(CDN)로 바꾸면 그 게이트가 통째로 우회되므로, 게이트가 살아 있는
+ * 다운로드 서빙 URL 을 돌려준다.
+ *
* @param string $slug 게시판 식별자
* @param int $id 첨부파일 ID
* @return string|null 파일 URL 또는 없을 경우 null
@@ -800,11 +804,7 @@ class AttachmentService
{
$attachment = $this->repository->findById($slug, $id);
- if (! $attachment) {
- return null;
- }
-
- return $this->storage->url('attachments', $attachment->path);
+ return $attachment?->download_url;
}
/**
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
index 89d4fa12..3cb320b6 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
@@ -823,29 +823,54 @@ class AttachmentServiceTest extends ModuleTestCase
HookManager::clearAction('sirsoft-board.attachment.after_download');
}
+ /**
+ * 업로드 응답의 url 칸이 게이트가 살아 있는 서빙 URL 이어야 합니다.
+ *
+ * 이전에는 비공개 디스크에서 항상 null 이 나가 응답의 url 칸이 늘 비어 있었다.
+ * 게시판 첨부는 비밀글·삭제글 게이트가 걸려 있으므로 직접 URL 로 바꾸지 않고,
+ * 그 게이트를 통과하는 다운로드 서빙 URL 로 채운다.
+ *
+ * @effects board_upload_response_url_uses_gated_route, download_url_falls_back_to_api_path_when_direct_unavailable
+ */
#[Test]
- public function test_get_url_returns_url(): void
+ public function test_get_url_returns_gated_serving_url(): void
{
// Arrange
$attachment = new Attachment([
- 'id' => 1,
'path' => 'notice/2025/01/21/test.jpg',
]);
+ $attachment->hash = 'abc123def456';
+ $attachment->setRelation('board', new Board(['slug' => 'notice']));
$this->repository->shouldReceive('findById')
->once()
->with('notice', 1)
->andReturn($attachment);
- $this->storage->shouldReceive('url')
- ->once()
- ->with('attachments', 'notice/2025/01/21/test.jpg')
- ->andReturn('https://example.com/storage/modules/sirsoft-board/attachments/notice/2025/01/21/test.jpg');
+ // 직접 URL 은 시도조차 하지 않는다 (게이트 우회 차단)
+ $this->storage->shouldNotReceive('url');
// Act
$result = $this->service->getUrl('notice', 1);
// Assert
- $this->assertEquals('https://example.com/storage/modules/sirsoft-board/attachments/notice/2025/01/21/test.jpg', $result);
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/notice/attachment/abc123def456',
+ $result
+ );
+ }
+
+ /**
+ * 첨부가 없으면 null 이어야 합니다 (기존 계약 유지).
+ */
+ #[Test]
+ public function test_get_url_returns_null_when_attachment_missing(): void
+ {
+ $this->repository->shouldReceive('findById')
+ ->once()
+ ->with('notice', 99)
+ ->andReturnNull();
+
+ $this->assertNull($this->service->getUrl('notice', 99));
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/data-model.md b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
index 09a5c71c..e79551d2 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
@@ -71,6 +71,13 @@
관계없이 같은 방식으로 자산 URL 을 내도록 묶습니다. 표에 모델처럼 잡힌 것은 수집기가 클래스
파일 단위로 세기 때문이며, 테이블이 `(규약)` 인 것이 그 표식입니다.
+이 규약은 **권한 검사가 걸린 자산에는 배선하지 않는 것이 원칙**입니다 — 직접 URL 은 서버
+스트리밍 경로의 검사를 우회합니다. 예외가 하나 있습니다: `ProductReviewImage` 는 이 규약을
+씁니다. 리뷰 노출 상태는 리소스 층에서 걸러지므로 숨김 리뷰의 이미지는 응답에 실리지
+않지만, 공개 자산 디스크를 켠 상태에서 **이미 발급된 직접 URL 을 아는 쪽은 노출→숨김 전환
+이후에도 그 파일에 접근할 수 있습니다.** 노출 상태를 접근 차단 수단으로 써야 하는 운용이라면
+그 카테고리에 공개 자산 디스크를 적용하지 않아야 합니다.
+
**`HasUserOverrides` 를 쓰는 셋**(`ClaimReason` · `ShippingCarrier` · `ShippingType`)은 시더가
기본값을 넣지만 운영자가 고칠 수 있는 테이블입니다. 시더를 다시 돌려도 운영자 수정분은
보존되므로, 이 셋의 기본 데이터를 바꿀 때는 시더만 고쳐서는 기설치본에 반영되지 않습니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
index 4e3a06f8..8d7e6bbf 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
@@ -16,6 +16,13 @@ use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
* 이미지 외의 완전 공개 자산 모델도 재사용할 수 있습니다. 단, 권한 검사가 걸린
* 첨부파일(비밀글/회원 전용 게시판 등)에는 배선하지 않습니다 — 직접 URL 은
* 서버 스트리밍 경로의 권한 검사를 우회합니다.
+ *
+ * 알려진 예외와 그 한계 — `ProductReviewImage` 는 이 trait 을 씁니다. 리뷰 노출 상태는
+ * 리소스 층에서 걸러지므로 HIDDEN 리뷰의 이미지는 응답에 실리지 않지만, 공개 자산
+ * 디스크를 켠 상태에서 **이미 발급된 직접 URL 을 알고 있는 쪽은 VISIBLE→HIDDEN 전환
+ * 이후에도 그 파일에 접근할 수 있습니다.** 직접 URL 은 저장소의 파일을 직접 가리키므로
+ * 응답에서 감추는 것으로는 회수되지 않습니다. 노출 상태를 접근 차단 수단으로 써야 하는
+ * 운용이라면 그 카테고리에 공개 자산 디스크를 적용하지 않아야 합니다.
*/
trait HasDirectAssetUrl
{
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index e2f12ec8..a76cecef 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-09-08
+
+### Removed
+
+- 사용되지 않던 첨부파일 주소 조회 기능을 제거했습니다. 화면·API 어디에서도 호출되지 않았고 항상 빈 값을 돌려주던 기능이라 실제 동작 변화는 없습니다.
+
## [1.1.1] - 2026-09-06
### Added
diff --git a/modules/_bundled/sirsoft-page/README.md b/modules/_bundled/sirsoft-page/README.md
index 7ccbed3e..8fb3e05c 100644
--- a/modules/_bundled/sirsoft-page/README.md
+++ b/modules/_bundled/sirsoft-page/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json
index 133f79e8..df6069c8 100644
--- a/modules/_bundled/sirsoft-page/composer.json
+++ b/modules/_bundled/sirsoft-page/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-page/composer.lock b/modules/_bundled/sirsoft-page/composer.lock
index b54bdee8..1b604797 100644
--- a/modules/_bundled/sirsoft-page/composer.lock
+++ b/modules/_bundled/sirsoft-page/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b5b9d14252427e57c4bc8c36e891c5de",
+ "content-hash": "024fea001a7af7fa7570756507a49ec2",
"packages": [],
"packages-dev": [],
"aliases": [],
diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json
index d5c43683..5b828dad 100644
--- a/modules/_bundled/sirsoft-page/module.json
+++ b/modules/_bundled/sirsoft-page/module.json
@@ -5,7 +5,7 @@
"ko": "페이지",
"en": "Page"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json
index e20deeb6..115fdf16 100644
--- a/modules/_bundled/sirsoft-page/package-lock.json
+++ b/modules/_bundled/sirsoft-page/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json
index 3047c5fa..617a3609 100644
--- a/modules/_bundled/sirsoft-page/package.json
+++ b/modules/_bundled/sirsoft-page/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
index c9cbd4f8..48edeb03 100644
--- a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
+++ b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
@@ -470,17 +470,6 @@ class PageAttachmentService
);
}
- /**
- * 파일 URL을 반환합니다.
- *
- * @param PageAttachment $attachment 첨부파일 모델
- * @return string|null 파일 URL 또는 null
- */
- public function getUrl(PageAttachment $attachment): ?string
- {
- return $this->storage->url('attachments', $attachment->path);
- }
-
/**
* 첨부파일 삭제 권한을 확인합니다.
*
diff --git a/resources/js/core/template-engine/layout-editor/__tests__/components/dimension-and-attachments.test.tsx b/resources/js/core/template-engine/layout-editor/__tests__/components/dimension-and-attachments.test.tsx
index dc0413a6..0966b7f4 100644
--- a/resources/js/core/template-engine/layout-editor/__tests__/components/dimension-and-attachments.test.tsx
+++ b/resources/js/core/template-engine/layout-editor/__tests__/components/dimension-and-attachments.test.tsx
@@ -177,4 +177,36 @@ describe('LayoutAttachmentManager', () => {
fireEvent.click(screen.getByTestId('g7le-attachment-delete-1'));
expect(fn.mock.calls.some((c) => (c[1] as RequestInit)?.method === 'DELETE')).toBe(false);
});
+ /**
+ * 공개 자산 스토리지를 켜면 서버가 교차 출처 절대 URL 을 돌려준다 (공개 #134).
+ * 화면은 그 문자열을 가공 없이 그대로 배경 CSS 와 선택 콜백에 흘려야 한다 —
+ * same-origin 상대 경로를 가정해 접두사를 붙이면 CDN 주소가 깨진다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects editor_thumbnail_renders_cross_origin, direct_url_when_row_matches_public_disk
+ */
+ it('교차 출처 CDN 절대 URL 을 썸네일·선택 콜백에 그대로 보존', async () => {
+ const cdnUrl = 'https://cdn.example.test/template-layout-attachments/sirsoft-basic/hero.png';
+ const cdnList = [{ ...sampleList[0], url: cdnUrl }];
+ mockFetch(() => ({ ok: true, json: async () => ({ success: true, data: cdnList }) }));
+
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ const card = await screen.findByTestId('g7le-attachment-card-1');
+ const thumb = card.firstElementChild as HTMLElement;
+ expect(thumb.style.backgroundImage).toContain(cdnUrl);
+
+ fireEvent.click(screen.getByTestId('g7le-attachment-use-1'));
+ expect(onSelect).toHaveBeenCalledWith(cdnUrl);
+ });
});
diff --git a/resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx b/resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
index 739ed085..b913aa82 100644
--- a/resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
+++ b/resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
@@ -201,6 +201,51 @@ describe('ImagePickerControl', () => {
await vi.waitFor(() => expect(screen.getByTestId('g7le-image-error')).toBeTruthy());
});
+ /**
+ * 공개 자산 스토리지를 켜면 업로드 응답의 url 이 교차 출처 절대 주소(CDN)로 온다 (공개 #134).
+ * 위젯은 그 문자열을 가공 없이 값으로 세워야 한다 — same-origin 상대 경로를 가정해
+ * 접두사를 붙이거나 정규화하면 CDN 주소가 깨지고, 그 값이 레이아웃에 그대로 저장된다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects direct_url_when_row_matches_public_disk, editor_thumbnail_renders_cross_origin
+ */
+ it('업로드 응답의 교차 출처 CDN 절대 URL 을 값으로 그대로 세운다', async () => {
+ const cdnUrl = 'https://cdn.example.test/template-layout-attachments/sirsoft-basic/hero.png';
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, data: { url: cdnUrl } }),
+ }),
+ );
+ const onChange = renderImage(undefined);
+ const file = new File(['x'], 'hero.png', { type: 'image/png' });
+ fireEvent.change(screen.getByTestId('g7le-image-file'), { target: { files: [file] } });
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalled());
+ const v = onChange.mock.calls.at(-1)![0] as Record;
+ expect(v.url).toBe(cdnUrl);
+ expect(v.size).toBe('cover'); // 기본 fill 모드 — 교차 출처여도 분해 규칙은 동일
+ });
+
+ /**
+ * 교차 출처 절대 URL 도 미리보기 배경 CSS 로 분해돼야 한다 (공개 #134).
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects editor_thumbnail_renders_cross_origin
+ */
+ it('교차 출처 CDN 절대 URL 을 배경 CSS 와 입력칸에 그대로 반영한다', () => {
+ const cdnUrl = 'https://cdn.example.test/template-layout-attachments/sirsoft-basic/hero.png';
+ renderImage({ url: cdnUrl, size: 'contain', repeat: 'no-repeat', position: 'top' });
+ const preview = screen.getByTestId('g7le-image-preview');
+ expect(preview.style.backgroundImage).toContain(cdnUrl);
+ expect(preview.style.backgroundSize).toBe('contain');
+ // jsdom 은 background-position 단일 키워드를 'center top' 으로 정규화한다
+ expect(preview.style.backgroundPosition).toContain('top');
+ expect(screen.getByTestId('g7le-image-url')).toHaveValue(cdnUrl);
+ });
+
it('`기본` → onChange(undefined)', () => {
const onChange = renderImage({ url: 'https://x/a.png' });
fireEvent.click(screen.getByTestId('g7le-image-clear'));
diff --git a/resources/js/core/template-engine/layout-editor/__tests__/utils/layoutAttachments.test.ts b/resources/js/core/template-engine/layout-editor/__tests__/utils/layoutAttachments.test.ts
new file mode 100644
index 00000000..3f438a33
--- /dev/null
+++ b/resources/js/core/template-engine/layout-editor/__tests__/utils/layoutAttachments.test.ts
@@ -0,0 +1,136 @@
+/**
+ * layoutAttachments.test.ts — 레이아웃 첨부 API 클라이언트 단위 테스트
+ *
+ * 서버가 돌려주는 `url` 은 설정에 따라 두 형태다 — 공개 서빙 라우트(프록시) 상대 경로,
+ * 또는 공개 자산 디스크(CDN)의 교차 출처 절대 URL (공개 #134). 클라이언트는 형태를
+ * 해석하거나 가공하지 않고 **서버가 준 문자열을 그대로 보존**해야 한다. 여기서 상대
+ * 경로를 가정해 접두사를 붙이거나 origin 을 붙이면 CDN 주소가 깨진다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects direct_url_when_row_matches_public_disk, proxy_url_otherwise
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ listLayoutAttachments,
+ uploadLayoutAttachment,
+ deleteLayoutAttachment,
+} from '../../utils/layoutAttachments';
+
+const PROXY_URL = '/api/templates/sirsoft-basic/layout-attachments/1/file';
+const CDN_URL = 'https://cdn.example.test/template-layout-attachments/sirsoft-basic/bg.png';
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ } as unknown as Response;
+}
+
+function attachment(url: string) {
+ return {
+ id: 1,
+ layout_name: 'home',
+ original_name: 'bg.png',
+ mime_type: 'image/png',
+ size: 178,
+ url,
+ };
+}
+
+describe('layoutAttachments — 첨부 API 클라이언트', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ // buildAuthHeaders 가 읽는 저장소 — 없어도 동작해야 하지만 명시해 둔다
+ try {
+ localStorage.setItem('auth_token', 'test-token');
+ } catch {
+ /* 저장소 미지원 환경 무시 */
+ }
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('목록 응답의 프록시 URL 을 가공 없이 보존한다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true, data: [attachment(PROXY_URL)] }),
+ );
+
+ const result = await listLayoutAttachments('sirsoft-basic', 'home');
+
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.data[0].url).toBe(PROXY_URL);
+ });
+
+ it('목록 응답의 교차 출처 CDN 절대 URL 을 가공 없이 보존한다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true, data: [attachment(CDN_URL)] }),
+ );
+
+ const result = await listLayoutAttachments('sirsoft-basic', 'home');
+
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.data[0].url).toBe(CDN_URL);
+ });
+
+ it('업로드 응답의 CDN 절대 URL 을 가공 없이 보존한다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true, data: attachment(CDN_URL) }),
+ );
+
+ const file = new File(['x'], 'bg.png', { type: 'image/png' });
+ const result = await uploadLayoutAttachment('sirsoft-basic', 'home', file);
+
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.data.url).toBe(CDN_URL);
+ });
+
+ it('업로드 응답에 url 이 없으면 실패로 처리한다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true, data: { id: 1 } }),
+ );
+
+ const file = new File(['x'], 'bg.png', { type: 'image/png' });
+ const result = await uploadLayoutAttachment('sirsoft-basic', 'home', file);
+
+ expect(result.ok).toBe(false);
+ });
+
+ it('레이아웃 이름의 slash 를 query 로 인코딩한다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true, data: [] }),
+ );
+
+ await listLayoutAttachments('sirsoft-basic', 'auth/login');
+
+ const calledUrl = (fetch as unknown as ReturnType).mock.calls[0][0] as string;
+ expect(calledUrl).toContain('layout_name=auth%2Flogin');
+ });
+
+ it('삭제는 첨부 id 경로로 DELETE 를 보낸다', async () => {
+ (fetch as unknown as ReturnType).mockResolvedValue(
+ jsonResponse({ success: true }),
+ );
+
+ const result = await deleteLayoutAttachment(1);
+
+ expect(result.ok).toBe(true);
+ const [calledUrl, init] = (fetch as unknown as ReturnType).mock.calls[0];
+ expect(calledUrl).toBe('/api/admin/templates/layout-attachments/1');
+ expect((init as RequestInit).method).toBe('DELETE');
+ });
+
+ it('네트워크 실패를 상태 0 의 에러로 돌려준다', async () => {
+ (fetch as unknown as ReturnType).mockRejectedValue(new Error('boom'));
+
+ const result = await listLayoutAttachments('sirsoft-basic', 'home');
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(0);
+ });
+});
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index e81d5e5a..37c7326f 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -17,6 +17,7 @@
### Fixed
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
+- 환경설정 > 드라이버의 「공개 자산 스토리지」 설명에 레이아웃 배경 이미지가 대상에 포함된다는 점과, 설정을 켠 뒤 올린 파일만 저장소 주소로 바뀌며 그 주소가 저장된 레이아웃에 남는다는 점을 덧붙였습니다.
## [1.0.8] - 2026-09-06
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
index 98c733bd..9c44e2df 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
@@ -1693,7 +1693,7 @@
},
"public_asset": {
"title": "Public Asset Storage",
- "desc": "Configure the disk for serving fully public assets (product, category, review, and editor images, etc.) via direct URLs. When disabled, the existing streaming method is used.",
+ "desc": "Configure the disk for serving fully public assets (product, category, review, and editor images, layout background images, etc.) via direct URLs. When disabled, the existing streaming method is used. Only files uploaded after enabling this get direct URLs, and those addresses stay in the saved layout.",
"disk": "Disk",
"s3_help": "When selecting S3, the S3 URL (CDN domain) in the File Storage card above must be configured. Without it, streaming is used."
},
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
index 425c4a22..fc659784 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
@@ -1697,7 +1697,7 @@
},
"public_asset": {
"title": "공개 자산 스토리지",
- "desc": "완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)의 직접 URL 서빙 디스크를 설정합니다. 사용 안 함이면 기존 스트리밍 방식으로 동작합니다.",
+ "desc": "완전 공개 자산(상품·카테고리·리뷰·에디터 이미지, 레이아웃 배경 이미지 등)의 직접 URL 서빙 디스크를 설정합니다. 사용 안 함이면 기존 스트리밍 방식으로 동작합니다. 켠 뒤 업로드한 파일만 직접 URL 로 발급되며, 그 주소는 저장된 레이아웃에 그대로 남습니다.",
"disk": "디스크",
"s3_help": "S3 선택 시 위 파일 스토리지 카드의 S3 URL(CDN 도메인) 설정이 필요합니다. 미설정 시 스트리밍으로 동작합니다."
},
diff --git a/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php b/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php
index aebe2566..92c0e0f8 100644
--- a/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php
+++ b/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php
@@ -326,4 +326,100 @@ class TemplateLayoutAttachmentControllerTest extends TestCase
$response->assertStatus(404);
}
+
+ /**
+ * 공개 자산 디스크로 쓸 가짜 CDN 디스크를 등록합니다.
+ *
+ * `Storage::fake()` 는 해석된 디스크 인스턴스만 교체하고 `filesystems.disks.*`
+ * config 는 건드리지 않는다. 공개 자산 디스크 게이트는 그 config 존재를 보므로,
+ * fake 만으로는 고아 디스크로 판정되어 프록시가 나온다.
+ */
+ private function registerFakeCdnDisk(): void
+ {
+ config(['filesystems.disks.fake_cdn' => [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/disks/fake_cdn'),
+ 'url' => 'https://cdn.test/assets',
+ ]]);
+ Storage::fake('fake_cdn', ['url' => 'https://cdn.test/assets']);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 공개 자산 디스크 — 업로드 저장 위치와 응답 URL 형태 (공개 #134)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * 공개 자산 디스크 미설정이면 기존 첨부 디스크에 저장되고 프록시 URL 이어야 합니다.
+ *
+ * @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects upload_stores_on_public_disk, proxy_url_otherwise
+ */
+ public function test_upload_without_public_asset_disk_uses_attachment_disk(): void
+ {
+ config(['core.storage.public_asset_disk' => '']);
+
+ $response = $this->withHeaders($this->authHeaders())
+ ->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
+ 'file' => UploadedFile::fake()->image('bg.png', 20, 20),
+ 'layout_name' => 'home',
+ ]);
+
+ $response->assertStatus(200);
+
+ $attachment = TemplateLayoutAttachment::first();
+ $this->assertSame(config('attachment.disk', 'attachments'), $attachment->disk);
+ $this->assertStringContainsString('/layout-attachments/', (string) $response->json('data.url'));
+ $this->assertStringContainsString('/file', (string) $response->json('data.url'));
+ }
+
+ /**
+ * 공개 자산 디스크가 선언되면 그 디스크에 저장되고 직접 URL 이 발급돼야 합니다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects upload_stores_on_public_disk, direct_url_when_row_matches_public_disk
+ */
+ public function test_upload_with_public_asset_disk_stores_there_and_returns_direct_url(): void
+ {
+ $this->registerFakeCdnDisk();
+ config(['core.storage.public_asset_disk' => 'fake_cdn']);
+
+ $response = $this->withHeaders($this->authHeaders())
+ ->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
+ 'file' => UploadedFile::fake()->image('cdn.png', 20, 20),
+ 'layout_name' => 'home',
+ ]);
+
+ $response->assertStatus(200);
+
+ $attachment = TemplateLayoutAttachment::first();
+ $this->assertSame('fake_cdn', $attachment->disk);
+ Storage::disk('fake_cdn')->assertExists('template-layout-attachments/'.$attachment->path);
+
+ $this->assertStringStartsWith('https://cdn.test/assets', (string) $response->json('data.url'));
+ }
+
+ /**
+ * 공개 자산 디스크를 켜도 그 이전에 올라간(다른 disk) 행은 프록시를 유지해야 합니다.
+ *
+ * @scenario public_asset_disk=public,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_legacy_row_keeps_proxy_url_after_enabling_public_asset_disk(): void
+ {
+ $legacy = $this->makeStoredAttachment();
+
+ $this->registerFakeCdnDisk();
+ config(['core.storage.public_asset_disk' => 'fake_cdn']);
+
+ $response = $this->withHeaders($this->authHeaders())
+ ->getJson("/api/admin/templates/{$this->template->identifier}/layout-attachments");
+
+ $response->assertStatus(200);
+ $url = (string) $response->json('data.0.url');
+ $this->assertStringContainsString("/layout-attachments/{$legacy->id}/file", $url);
+ $this->assertStringNotContainsString('cdn.test', $url);
+ }
}
diff --git a/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts b/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts
new file mode 100644
index 00000000..1c6d4c23
--- /dev/null
+++ b/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts
@@ -0,0 +1,282 @@
+/**
+ * Layout Editor — 레이아웃 첨부 URL 발급 모드 왕복 (공개 #134).
+ *
+ * 결함: 업로드 응답의 `url` 이 스토리지 설정과 무관하게 항상 공개 서빙 라우트(프록시)로
+ * 발급되어, 공개 자산 디스크(S3+CDN)를 설정해도 방문자 요청이 매번 오리진 PHP 를 거쳤다.
+ *
+ * 이 spec 은 첨부를 **실제로 업로드해 왕복**한다 — 기존 background-image-render.spec 은
+ * URL 을 손으로 입력할 뿐 업로드 경로를 타지 않아 이 결함을 원리상 포착할 수 없었다.
+ * 기본 설치(공개 자산 디스크 미설정) 상태에서 업로드 응답 URL 이 프록시 형태이고 그
+ * 주소가 인증 없이 200 으로 열리는지, 그리고 그 URL 이 위젯 값·캔버스에 그대로
+ * 반영되는지를 본다. 직접 URL(CDN) 축은 서버 설정 변경이 필요하므로 브라우저 실측
+ * 매트릭스가 담당하고, 여기서는 서버 응답 형태를 가공 없이 보존하는지를 고정한다.
+ *
+ * 축 요약(마커 아님 — 평문): public_asset_disk=unset, row_disk=attachments,
+ * filter_url_hook=absent.
+ */
+import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
+import { bodyRootPath } from '../../fixtures/layout-editor';
+import { SANDBOX_ROOT_ID, SANDBOX_ROUTE, sandboxRouteParam } from '../../fixtures/seed-layout';
+import type { Page } from '@playwright/test';
+
+/** 1x1 투명 PNG (base64) — 업로드 픽스처 */
+const PNG_BASE64 =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
+
+/**
+ * 캔버스의 배경이 보일 만한 박스형 Div 영역을 골라 path 를 돌려준다.
+ * 좌측 라우트 트리 패널과 겹치지 않도록 캔버스 안쪽(left > 360)으로 한정한다.
+ */
+async function pickBoxAreaPath(page: Page): Promise {
+ return page.evaluate(() => {
+ const cands = Array.from(
+ document.querySelectorAll('[data-editor-path][data-editor-name="Div"]'),
+ ).filter((el) => {
+ const r = el.getBoundingClientRect();
+ return r.width > 200 && r.width < 800 && r.height > 80 && r.height < 320 && r.left > 360;
+ });
+ return cands[0]?.getAttribute('data-editor-path') ?? null;
+ });
+}
+
+/** 캔버스 위임 click 핸들러로 노드를 선택한다(오버레이가 좌표를 가로채는 것 회피). */
+async function selectNode(page: Page, path: string): Promise {
+ await page.evaluate((p) => {
+ const el = document.querySelector(`[data-editor-path="${p}"]`);
+ if (!el) return;
+ el.scrollIntoView({ block: 'center' });
+ const r = el.getBoundingClientRect();
+ const cx = r.left + r.width / 2;
+ const cy = r.top + r.height / 2;
+ const types = [
+ 'pointerover',
+ 'pointermove',
+ 'pointerdown',
+ 'mousedown',
+ 'pointerup',
+ 'mouseup',
+ 'click',
+ ];
+ for (const type of types) {
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
+ }
+ }, path);
+}
+
+async function openStyleTab(page: Page): Promise {
+ await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 });
+ await page.getByTestId('g7le-overlay-info-button').click();
+ await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 });
+ await page.getByTestId('g7le-context-menu-edit-props').click();
+ await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 });
+ await page.getByTestId('g7le-property-tab-style').click();
+}
+
+async function enterEditor(page: Page): Promise {
+ await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F');
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+ await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
+ await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
+ timeout: 20_000,
+ });
+}
+
+/** 첨부 1건을 업로드하고 `{ id, url }` 을 돌려준다. */
+async function uploadAttachment(
+ page: Page,
+ token: string,
+ filename: string,
+): Promise<{ id: number | null; url: string | null; status: number }> {
+ return page.evaluate(
+ async ({ b64, bearer, name }) => {
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
+ const form = new FormData();
+ form.append('file', new File([bytes], name, { type: 'image/png' }), name);
+ form.append('layout_name', 'home');
+ const res = await fetch('/api/admin/templates/sirsoft-basic/layout-attachments', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
+ body: form,
+ credentials: 'same-origin',
+ });
+ const body = await res.json().catch(() => null);
+ return { status: res.status, id: body?.data?.id ?? null, url: body?.data?.url ?? null };
+ },
+ { b64: PNG_BASE64, bearer: token, name: filename },
+ );
+}
+
+/** 이 spec 이 만든 첨부를 지운다 (공유 사이트에 잔여물을 남기지 않는다). */
+async function deleteAttachment(page: Page, token: string, id: number | null): Promise {
+ if (id === null) return;
+ await page.evaluate(
+ async ({ attachmentId, bearer }) => {
+ await fetch(`/api/admin/templates/layout-attachments/${attachmentId}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
+ credentials: 'same-origin',
+ });
+ },
+ { attachmentId: id, bearer: token },
+ );
+}
+
+test.describe('@layout-editor 레이아웃 첨부 URL 발급 모드', () => {
+ /**
+ * @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ test('업로드 → 응답 URL 이 프록시 형태이고 인증 없이 열린다', async ({ page }) => {
+ const token = issueToken('core.templates.layouts.edit');
+ await authenticatePage(page, token);
+ // 이 축은 서버 응답 형태만 보므로 편집기 진입이 필요 없다 — same-origin 페이지면 충분하다.
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ const uploaded = await uploadAttachment(page, token, 'e2e-url-mode.png');
+
+ expect(uploaded.status).toBe(200);
+ expect(uploaded.url).toBeTruthy();
+
+ // 공개 자산 디스크 미설정 = 기본 설치 → 공개 서빙 라우트(프록시) 형태
+ expect(uploaded.url as string).toContain(`/layout-attachments/${uploaded.id}/file`);
+
+ // 그 주소는 인증 없이 이미지로 열려야 한다 (발행 배경은 방문자에게 로드된다)
+ const served = await page.evaluate(async (url: string) => {
+ const res = await fetch(url, { credentials: 'omit' });
+ return { status: res.status, type: res.headers.get('content-type') };
+ }, uploaded.url as string);
+ expect(served.status).toBe(200);
+ expect(served.type ?? '').toContain('image/');
+
+ await deleteAttachment(page, token, uploaded.id);
+ });
+
+ /**
+ * 서버가 준 URL 문자열이 위젯 값과 캔버스 배경에 가공 없이 반영돼야 한다.
+ * 상대 경로를 가정해 접두사를 붙이면 직접 URL(CDN) 모드에서 주소가 깨진다.
+ *
+ * @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects editor_thumbnail_renders_cross_origin
+ */
+ test('업로드한 첨부 URL 이 위젯 값·캔버스 배경에 그대로 반영된다', async ({ page }) => {
+ const token = issueToken('core.templates.layouts.edit');
+ await authenticatePage(page, token);
+ await enterEditor(page);
+
+ const uploaded = await uploadAttachment(page, token, 'e2e-bind.png');
+ expect(uploaded.url).toBeTruthy();
+
+ const targetPath = await pickBoxAreaPath(page);
+ expect(targetPath).not.toBeNull();
+ await selectNode(page, targetPath as string);
+ await openStyleTab(page);
+
+ const urlInput = page.getByTestId('g7le-image-url');
+ await urlInput.fill(uploaded.url as string);
+ await urlInput.blur();
+
+ // 입력칸이 서버 URL 을 그대로 보존
+ await expect(urlInput).toHaveValue(uploaded.url as string);
+
+ // 캔버스 inline background-image 에도 같은 주소가 실린다
+ await expect
+ .poll(
+ async () =>
+ page.evaluate((p) => {
+ const el = document.querySelector(`[data-editor-path="${p}"]`);
+ return el ? getComputedStyle(el).backgroundImage : '';
+ }, targetPath),
+ { timeout: 8_000 },
+ )
+ .toContain(`/layout-attachments/${uploaded.id}/file`);
+
+ // 저장하지 않았으므로 레이아웃은 그대로. 첨부만 삭제한다.
+ await deleteAttachment(page, token, uploaded.id);
+ });
+
+ /**
+ * 저장까지 왕복해 **방문자 화면이 실제로 요청하는 주소**를 확인한다.
+ *
+ * 앞의 두 테스트는 업로드 응답과 편집기 화면까지만 본다. 이 결함의 최종 증상은 방문자
+ * 요청이 어디로 나가느냐이므로, 저장된 레이아웃이 그리는 화면에서 그 주소가 실제로
+ * 요청되고 200 으로 응답하는지까지 봐야 축이 닫힌다.
+ *
+ * 저장(PUT)은 편집 결과가 그대로 영속되므로 제품 화면(`home`)이 아니라 E2E 전용 시드
+ * 화면(`e2e_sandbox`)을 대상으로 한다 — globalSetup 이 매 실행 fixture 원본으로 덮어쓰므로
+ * 원복 절차가 필요 없다(원복 자체가 또 한 번의 저장이라 실패 시 잔여물이 남는다).
+ *
+ * @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise, saved_layout_url_is_what_visitor_requests
+ */
+ test('배경 적용 → 저장 → 방문자 화면이 그 주소를 요청한다', async ({ page }) => {
+ test.setTimeout(90_000); // 업로드 + 저장 + 방문자 렌더 합산
+
+ const token = issueToken('core.templates.layouts.edit');
+ await authenticatePage(page, token);
+
+ await page.goto(`/admin/layout-editor/sirsoft-basic?route=${sandboxRouteParam()}`);
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+ await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
+ await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
+ timeout: 20_000,
+ });
+
+ const uploaded = await uploadAttachment(page, token, 'e2e-save-roundtrip.png');
+ expect(uploaded.url).toBeTruthy();
+
+ // 시드 화면은 본문 컨테이너 id 가 고정이라 후보 순회 없이 곧바로 지목할 수 있다.
+ const rootPath = await bodyRootPath(page, SANDBOX_ROOT_ID);
+ await selectNode(page, rootPath);
+ await openStyleTab(page);
+
+ const urlInput = page.getByTestId('g7le-image-url');
+ await urlInput.fill(uploaded.url as string);
+ await urlInput.blur();
+ await expect(urlInput).toHaveValue(uploaded.url as string);
+
+ // 모달을 확실히 닫은 뒤 툴바 저장 — 모달이 열린 채면 안쪽 버튼이 잡혀 PUT 이 안 난다.
+ await page
+ .getByTestId('g7le-property-modal-done')
+ .click({ timeout: 2_000 })
+ .catch(() => undefined);
+ await page
+ .getByRole('button', { name: /^(닫기|Close)$/ })
+ .first()
+ .click({ timeout: 3_000 })
+ .catch(() => undefined);
+ await page.waitForTimeout(300);
+
+ const savePromise = page.waitForResponse(
+ (r) =>
+ /\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) &&
+ r.request().method() === 'PUT',
+ { timeout: 20_000 },
+ );
+ await page.getByTestId('g7le-toolbar-save').click();
+ const saveRes = await savePromise;
+ expect(saveRes.status()).toBe(200);
+
+ // 방문자 화면 — 저장된 문자열이 실제 요청으로 나가는지 본다.
+ const visitor = await page.context().newPage();
+ const attachmentRequests: number[] = [];
+ visitor.on('response', (res) => {
+ if (res.url() === uploaded.url) attachmentRequests.push(res.status());
+ });
+ await visitor.goto(SANDBOX_ROUTE['sirsoft-basic']);
+ await visitor.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+ await expect
+ .poll(() => attachmentRequests.length, { timeout: 15_000 })
+ .toBeGreaterThan(0);
+ expect(attachmentRequests.every((s) => s === 200)).toBe(true);
+ await visitor.close();
+
+ // 시드 화면은 다음 실행에서 fixture 로 덮이므로 첨부만 정리한다.
+ await deleteAttachment(page, token, uploaded.id);
+ });
+});
diff --git a/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php b/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php
index 71961ebc..45e7ae0a 100644
--- a/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php
+++ b/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php
@@ -83,6 +83,11 @@ class StorageCategoryDiskTest extends TestCase
{
return ['ko' => '테스트', 'en' => 'Test'];
}
+
+ public function exposedResolvePublicAssetDisk(?string $override = null): ?string
+ {
+ return $this->resolvePublicAssetDisk($override);
+ }
};
}
@@ -209,4 +214,83 @@ class StorageCategoryDiskTest extends TestCase
$this->assertNull($module->exposedResolvePublicAssetDisk());
$this->assertNull($module->exposedResolvePublicAssetDisk('vanished_plugin_disk'));
}
+
+ /**
+ * 플러그인 사본도 모듈과 동일한 해석 규칙을 따라야 합니다.
+ *
+ * 위임 이관(공개 #134) 전까지 플러그인 사본은 직접 테스트가 0건이라 오타가 나도
+ * 아무도 잡지 못하는 자리였다. 모듈과 같은 4축을 플러그인 더블에도 돌린다.
+ *
+ * @effects none_override_forces_streaming_over_global, orphan_disk_falls_back_to_streaming
+ */
+ #[Test]
+ public function plugin_resolve_public_asset_disk_matches_module_rules(): void
+ {
+ $plugin = $this->makePlugin();
+
+ // 미설정 → 스트리밍
+ $this->assertNull($plugin->exposedResolvePublicAssetDisk());
+
+ // 전역 설정 → 그 값
+ Config::set('core.storage.public_asset_disk', 'fake_cdn');
+ $this->assertSame('fake_cdn', $plugin->exposedResolvePublicAssetDisk());
+ $this->assertSame('fake_cdn', $plugin->exposedResolvePublicAssetDisk(''));
+
+ // override 우선
+ $this->assertSame('public', $plugin->exposedResolvePublicAssetDisk('public'));
+
+ // 'none' override → 스트리밍 강제
+ $this->assertNull($plugin->exposedResolvePublicAssetDisk('none'));
+
+ // 고아 디스크 → 스트리밍 폴백
+ Config::set('core.storage.public_asset_disk', 'vanished_plugin_disk');
+ $this->assertNull($plugin->exposedResolvePublicAssetDisk());
+ }
+
+ /**
+ * getStorageUrl()/getStorageBasePath() 가 카테고리에 배선된 디스크를 따라야 합니다.
+ *
+ * 기본 디스크를 보던 시절에는 images 를 공개 자산 디스크로 옮긴 확장이 이 API 로
+ * 항상 null 을 받았다 (공개 #134 전수조사 F4·F7).
+ *
+ * @effects url_returned_for_public_and_url_configured_disk
+ */
+ #[Test]
+ public function storage_url_and_base_path_follow_category_disk(): void
+ {
+ $module = new class extends AbstractModule
+ {
+ public function getName(): array
+ {
+ return ['ko' => '테스트', 'en' => 'Test'];
+ }
+
+ public function getVersion(): string
+ {
+ return '1.0.0';
+ }
+
+ public function getDescription(): array
+ {
+ return ['ko' => '테스트', 'en' => 'Test'];
+ }
+
+ public function getStorageDiskFor(string $category): string
+ {
+ return $category === 'images' ? 'fake_cdn' : $this->getStorageDisk();
+ }
+ };
+
+ // 배선된 카테고리 → fake_cdn 의 url 설정을 따라 직접 URL
+ $url = $module->getStorageUrl('images', 'a/b.png');
+ $this->assertNotNull($url);
+ $this->assertStringStartsWith('https://cdn.test/assets', $url);
+
+ // 미배선 카테고리는 기본 디스크(직접 URL 불가) → null
+ $this->assertNull($module->getStorageUrl('settings', 'a/b.json'));
+
+ // 기본 경로도 배선된 디스크 기준
+ $this->assertStringContainsString('fake_cdn', $module->getStorageBasePath('images'));
+ $this->assertStringNotContainsString('fake_cdn', $module->getStorageBasePath('settings'));
+ }
}
diff --git a/tests/Unit/Services/TemplateLayoutAttachmentServingTest.php b/tests/Unit/Services/TemplateLayoutAttachmentServingTest.php
index b215918b..696839d1 100644
--- a/tests/Unit/Services/TemplateLayoutAttachmentServingTest.php
+++ b/tests/Unit/Services/TemplateLayoutAttachmentServingTest.php
@@ -121,4 +121,35 @@ class TemplateLayoutAttachmentServingTest extends TestCase
$this->assertNull($result);
}
+
+ /**
+ * 고아 disk 행은 withDisk 없이 주입 스토리지로 서빙돼야 합니다 (500 회귀 가드).
+ *
+ * 공개 자산 디스크가 플러그인 등록 디스크일 수 있으므로, 그 플러그인이 비활성화되면
+ * 행의 disk 가 config 에서 사라진다. 미등록 disk 로 withDisk 를 만들면 이후
+ * response() 가 InvalidArgumentException 을 던져 **무인증 공개 서빙 라우트가 500**
+ * 이 된다. 파일 도달 불가는 404 로 끝나는 것이 정상 degradation 이다.
+ *
+ * @scenario public_asset_disk=ghost_disk,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects orphan_row_disk_serves_without_exception, proxy_url_otherwise
+ */
+ public function test_orphan_row_disk_falls_back_to_injected_storage(): void
+ {
+ $template = new Template;
+ $template->id = 7;
+ $this->templateRepository->shouldReceive('findByIdentifier')
+ ->with('sirsoft-basic')->andReturn($template);
+
+ // 고아 disk 로는 withDisk 가 불려서는 안 된다
+ $this->storage->shouldNotReceive('withDisk');
+ $this->storage->shouldReceive('response')->once()->andReturnNull();
+
+ $result = $this->service->getServableResponse(
+ 'sirsoft-basic',
+ $this->makeAttachment(7, 'vanished_plugin_disk')
+ );
+
+ $this->assertNull($result);
+ }
}
diff --git a/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php b/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php
new file mode 100644
index 00000000..09cccb0e
--- /dev/null
+++ b/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php
@@ -0,0 +1,258 @@
+storage = Mockery::mock(StorageInterface::class);
+
+ $this->service = new TemplateLayoutAttachmentService(
+ Mockery::mock(TemplateLayoutAttachmentRepositoryInterface::class),
+ Mockery::mock(TemplateRepositoryInterface::class),
+ $this->storage
+ );
+ }
+
+ /** 템플릿 관계가 로드된 첨부를 만듭니다 (DB 미저장). */
+ private function makeAttachment(string $disk): TemplateLayoutAttachment
+ {
+ $template = new Template;
+ $template->id = 7;
+ $template->identifier = 'sirsoft-basic';
+
+ $attachment = new TemplateLayoutAttachment([
+ 'layout_name' => 'home',
+ 'disk' => $disk,
+ 'path' => 'sirsoft-basic/2026/09/08/bg.png',
+ 'original_name' => 'bg.png',
+ 'mime_type' => 'image/png',
+ 'size' => 178,
+ ]);
+ $attachment->id = 1;
+ $attachment->template_id = 7;
+ $attachment->setRelation('template', $template);
+
+ return $attachment;
+ }
+
+ /** 프록시(공개 서빙 라우트) URL 인지 단언합니다. */
+ private function assertProxyUrl(string $url): void
+ {
+ $this->assertStringContainsString('/layout-attachments/1/file', $url);
+ }
+
+ /**
+ * 공개 자산 디스크 미설정 — 기본 설치. 프록시 URL 이어야 합니다.
+ *
+ * @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_unset_public_asset_disk_returns_proxy_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', '');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('attachments')));
+ }
+
+ /**
+ * 공개 자산 디스크 미설정 + 행 disk 가 s3 + s3.url 설정 — 프록시여야 합니다.
+ *
+ * 이 케이스가 §1 의 함정이다. `url()` non-null 이면 우선한다는 규칙을 문자
+ * 그대로 적용하면, 비공개 버킷 + `AWS_URL` 설정 환경에서 발급된 직접 URL 이
+ * 403 이 되어 배경 이미지가 전부 깨진다. 회귀 방지 축으로 가장 중요하다.
+ *
+ * @scenario public_asset_disk=unset,row_disk=s3,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_private_s3_row_with_url_config_still_returns_proxy_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', '');
+ Config::set('attachment.disk', 's3');
+ Config::set('filesystems.disks.s3.url', 'https://bucket.s3.ap-northeast-2.amazonaws.com');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('s3')));
+ }
+
+ /**
+ * 공개 자산 디스크 설정 + 행 disk 일치 — 직접 URL 이어야 합니다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
+ *
+ * @effects direct_url_when_row_matches_public_disk
+ */
+ public function test_matching_public_asset_disk_returns_direct_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->storage->shouldReceive('withDisk')->once()->with('public')->andReturnSelf();
+ $this->storage->shouldReceive('url')->once()
+ ->with('template-layout-attachments', 'sirsoft-basic/2026/09/08/bg.png')
+ ->andReturn('https://cdn.example.test/template-layout-attachments/sirsoft-basic/2026/09/08/bg.png');
+
+ $this->assertSame(
+ 'https://cdn.example.test/template-layout-attachments/sirsoft-basic/2026/09/08/bg.png',
+ $this->service->resolveUrl($this->makeAttachment('public'))
+ );
+ }
+
+ /**
+ * 공개 자산 디스크 설정 + 행 disk 불일치(설정 이전 업로드분) — 프록시여야 합니다.
+ *
+ * @scenario public_asset_disk=public,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_legacy_row_on_other_disk_returns_proxy_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('attachments')));
+ }
+
+ /**
+ * 설정된 공개 자산 디스크가 config 에 없으면(고아) 프록시여야 합니다.
+ *
+ * @scenario public_asset_disk=ghost_disk,row_disk=attachments,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_orphan_public_asset_disk_returns_proxy_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'vanished_plugin_disk');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('vanished_plugin_disk')));
+ }
+
+ /**
+ * `core.storage.filter_url` 훅이 URL 을 차단해 url() 이 null 이면 프록시로 폴백해야 합니다.
+ *
+ * @scenario public_asset_disk=public,row_disk=public,filter_url_hook=blocks
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_blocked_url_hook_falls_back_to_proxy_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->storage->shouldReceive('withDisk')->once()->with('public')->andReturnSelf();
+ $this->storage->shouldReceive('url')->once()->andReturnNull();
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('public')));
+ }
+
+ /**
+ * 공개 자산 디스크가 s3(비공개 버킷)이고 행 disk 도 s3 — 직접 URL 이 발급돼야 합니다.
+ *
+ * 운영자가 "이 저장소는 공개다" 라고 **선언한** 결과이므로 시스템은 그 선언을 따른다.
+ * 버킷이 실제로는 비공개여서 그 주소가 403 이 되는 것은 설정 책임이며, 문서의
+ * "버킷/CDN 쪽 요구사항" 이 그 조건을 명시한다. 선언하지 않은 상태(unset)에서
+ * 같은 행이 프록시로 남는 것과 대비되는 축이다.
+ *
+ * @scenario public_asset_disk=s3_private,row_disk=s3,filter_url_hook=absent
+ *
+ * @effects direct_url_when_row_matches_public_disk
+ */
+ public function test_declared_s3_disk_with_matching_row_returns_direct_url(): void
+ {
+ Config::set('core.storage.public_asset_disk', 's3');
+ Config::set('filesystems.disks.s3.url', 'https://bucket.s3.ap-northeast-2.amazonaws.com');
+
+ $this->storage->shouldReceive('withDisk')->once()->with('s3')->andReturnSelf();
+ $this->storage->shouldReceive('url')->once()
+ ->andReturn('https://bucket.s3.ap-northeast-2.amazonaws.com/template-layout-attachments/sirsoft-basic/2026/09/08/bg.png');
+
+ $this->assertSame(
+ 'https://bucket.s3.ap-northeast-2.amazonaws.com/template-layout-attachments/sirsoft-basic/2026/09/08/bg.png',
+ $this->service->resolveUrl($this->makeAttachment('s3'))
+ );
+ }
+
+ /**
+ * 공개 자산 디스크가 s3 인데 행 disk 가 public — 불일치이므로 프록시여야 합니다.
+ *
+ * 공개 자산 디스크를 public → s3 로 재구성한 뒤 남은 과거 행의 상황이다.
+ * 파일은 public 에 그대로 있고 프록시가 행 disk 로 서빙하므로 화면은 정상이다.
+ *
+ * @scenario public_asset_disk=s3_private,row_disk=public,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_reconfigured_public_asset_disk_demotes_old_rows_to_proxy(): void
+ {
+ Config::set('core.storage.public_asset_disk', 's3');
+ Config::set('filesystems.disks.s3.url', 'https://bucket.s3.ap-northeast-2.amazonaws.com');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('public')));
+ }
+
+ /**
+ * 공개 자산 디스크가 public 인데 행 disk 가 s3 — 불일치이므로 프록시여야 합니다.
+ *
+ * 첨부 디스크(s3)와 공개 자산 디스크(public)를 다르게 둔 이상적 혼재 구성에서,
+ * 설정 이전에 s3 로 올라간 행이 직접 URL 로 승격되지 않아야 한다.
+ *
+ * @scenario public_asset_disk=public,row_disk=s3,filter_url_hook=absent
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_mixed_disks_keep_old_s3_rows_on_proxy(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+ Config::set('attachment.disk', 's3');
+ Config::set('filesystems.disks.s3.url', 'https://bucket.s3.ap-northeast-2.amazonaws.com');
+ $this->storage->shouldNotReceive('withDisk');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('s3')));
+ }
+
+ /**
+ * 훅이 차단하는데 행 disk 가 공개 자산 디스크와 불일치 — 훅이 발화하지도 않고 프록시여야 합니다.
+ *
+ * 게이트가 url() 호출 앞에 있으므로 훅은 개입할 기회조차 얻지 못한다.
+ *
+ * @scenario public_asset_disk=public,row_disk=s3,filter_url_hook=blocks
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_hook_never_fires_when_row_disk_does_not_match(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+ $this->storage->shouldNotReceive('withDisk');
+ $this->storage->shouldNotReceive('url');
+
+ $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('s3')));
+ }
+}
diff --git a/tests/Unit/Support/PublicAssetDiskTest.php b/tests/Unit/Support/PublicAssetDiskTest.php
new file mode 100644
index 00000000..a18b05aa
--- /dev/null
+++ b/tests/Unit/Support/PublicAssetDiskTest.php
@@ -0,0 +1,129 @@
+assertNull(PublicAssetDisk::resolve());
+ }
+
+ /**
+ * 'none' 은 명시적 스트리밍 유지 선언이므로 null 이어야 합니다.
+ */
+ public function test_none_resolves_to_null(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'none');
+
+ $this->assertNull(PublicAssetDisk::resolve());
+ }
+
+ /**
+ * config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 이어야 합니다.
+ *
+ * @scenario public_asset_disk=ghost_disk
+ *
+ * @effects proxy_url_otherwise
+ */
+ public function test_orphan_disk_resolves_to_null(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'vanished_plugin_disk');
+
+ $this->assertNull(PublicAssetDisk::resolve());
+ }
+
+ /**
+ * config 에 존재하는 디스크는 그대로 돌려주어야 합니다.
+ */
+ public function test_existing_disk_resolves_to_itself(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->assertSame('public', PublicAssetDisk::resolve());
+ }
+
+ /**
+ * override 가 비어 있지 않으면 코어 전역 설정보다 우선해야 합니다.
+ */
+ public function test_override_takes_precedence_over_global(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->assertSame('local', PublicAssetDisk::resolve('local'));
+ }
+
+ /**
+ * override 가 ''/null 이면 코어 전역 설정을 사용해야 합니다.
+ */
+ public function test_blank_override_falls_back_to_global(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->assertSame('public', PublicAssetDisk::resolve(''));
+ $this->assertSame('public', PublicAssetDisk::resolve(null));
+ }
+
+ /**
+ * override 로 'none' 을 주면 전역 설정이 있어도 스트리밍을 강제해야 합니다.
+ */
+ public function test_none_override_forces_streaming_over_global(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+
+ $this->assertNull(PublicAssetDisk::resolve('none'));
+ }
+
+ /**
+ * isCurrent() 는 resolve() 를 경유하므로 ''/'none'/고아 디스크가
+ * 등가 비교로 통과하지 않아야 합니다.
+ */
+ public function test_is_current_never_matches_blank_none_or_orphan(): void
+ {
+ Config::set('core.storage.public_asset_disk', '');
+ $this->assertFalse(PublicAssetDisk::isCurrent(''));
+
+ Config::set('core.storage.public_asset_disk', 'none');
+ $this->assertFalse(PublicAssetDisk::isCurrent('none'));
+
+ Config::set('core.storage.public_asset_disk', 'vanished_plugin_disk');
+ $this->assertFalse(PublicAssetDisk::isCurrent('vanished_plugin_disk'));
+
+ Config::set('core.storage.public_asset_disk', 'public');
+ $this->assertTrue(PublicAssetDisk::isCurrent('public'));
+ $this->assertFalse(PublicAssetDisk::isCurrent('attachments'));
+ $this->assertFalse(PublicAssetDisk::isCurrent(null));
+ }
+
+ /**
+ * memoize 금지 — 런타임 Config::set 변경이 즉시 반영되어야 합니다.
+ */
+ public function test_resolution_is_not_memoized(): void
+ {
+ Config::set('core.storage.public_asset_disk', 'public');
+ $this->assertSame('public', PublicAssetDisk::resolve());
+
+ Config::set('core.storage.public_asset_disk', '');
+ $this->assertNull(PublicAssetDisk::resolve());
+ }
+}
diff --git a/tests/scenarios/layout-attachment-url-mode.yaml b/tests/scenarios/layout-attachment-url-mode.yaml
new file mode 100644
index 00000000..094ba840
--- /dev/null
+++ b/tests/scenarios/layout-attachment-url-mode.yaml
@@ -0,0 +1,61 @@
+feature: 레이아웃 첨부 URL 발급 모드 (공개 #134)
+
+description: |
+ 레이아웃 편집기에서 올린 배경 이미지의 주소가 항상 공개 서빙 라우트(프록시)로
+ 발급되어, 공개 자산 디스크(S3+CDN 등)를 설정해도 방문자 요청이 매번 오리진 PHP 를
+ 거치던 결함을 고친다.
+
+ 결정 사슬:
+ - 업로드 저장 위치: PublicAssetDisk::resolve() ?? config('attachment.disk').
+ 행이 자기 disk 를 기록하므로 이후 설정 변경과 무관하게 각 행은 자기 위치를 기억한다.
+ - URL 해석: 행 disk 가 **지금** 설정된 공개 자산 디스크와 일치할 때만 직접 URL 시도,
+ 그 외에는 프록시. `filesystems.disks.{disk}.url` 존재 여부는 판정 근거가 아니다
+ (비공개 버킷 + 공개 URL 설정 조합에서 직접 URL 은 403).
+ - 훅이 URL 을 차단하면(url() null) 프록시로 폴백한다.
+ - 고아 disk 행(디스크를 제공하던 플러그인 비활성화)은 withDisk 없이 주입 스토리지로
+ 서빙·삭제 — 무인증 공개 라우트가 500 이 되지 않고 404 로 끝난다.
+ - 게시판·페이지 첨부는 권한 게이트가 걸려 있어 직접 URL 배선 대상이 아니다.
+ 게시판 업로드 응답의 url 칸은 게이트가 살아 있는 서빙 URL 로 채운다.
+
+coverage_strategy: pairwise
+
+axes:
+ public_asset_disk: [unset, public, s3_private, ghost_disk]
+ row_disk: [attachments, s3, public]
+ filter_url_hook: [absent, blocks]
+
+exclusions:
+ - { public_asset_disk: unset, row_disk: public, reason: "공개 디스크 미설정이면 public 행이 새로 생기지 않는다" }
+ - { public_asset_disk: ghost_disk, row_disk: public, reason: "고아 디스크는 resolve 단계에서 null — row_disk 축 도달 전 차단" }
+ - { public_asset_disk: ghost_disk, row_disk: s3, reason: "동일 — resolve 가 null 이라 행 disk 와 무관하게 프록시" }
+ - { public_asset_disk: s3_private, row_disk: attachments, reason: "비공개 s3 는 판정 근거가 아니므로 unset 축과 결과가 동일" }
+ - { filter_url_hook: blocks, public_asset_disk: unset, reason: "직접 URL 시도 자체가 없어 훅이 발화하지 않는다" }
+ - { filter_url_hook: blocks, public_asset_disk: ghost_disk, reason: "동일 — 게이트에서 차단되어 url() 미호출" }
+
+effects:
+ - direct_url_when_row_matches_public_disk
+ - proxy_url_otherwise
+ - upload_stores_on_public_disk
+ - orphan_row_disk_serves_without_exception
+ - board_upload_response_url_uses_gated_route
+ - editor_thumbnail_renders_cross_origin
+ # 저장까지 왕복해 방문자 화면이 실제로 요청하는 주소를 본다 (E2E 시드 화면 대상)
+ - saved_layout_url_is_what_visitor_requests
+
+test_files:
+ - tests/Unit/Support/PublicAssetDiskTest.php
+ - tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php
+ - tests/Unit/Services/TemplateLayoutAttachmentServingTest.php
+ - tests/Unit/Extension/Storage/StorageCategoryDiskTest.php
+ - tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php
+ - modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
+ - resources/js/core/template-engine/layout-editor/__tests__/utils/layoutAttachments.test.ts
+ - resources/js/core/template-engine/layout-editor/__tests__/components/dimension-and-attachments.test.tsx
+ - resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
+ - tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts
+
+rules_layer_coverage:
+ - rule: no-storage-disk-direct
+ coverage: 모든 접근이 StorageInterface 경유 — Storage facade 직접 호출 도입 없음
+ - rule: repository-interface-injection
+ coverage: 서비스 시그니처 무변경 — 해석 규칙만 교체