From 1ba67ca7cfda3bb29a29507eefd1c0021970c7eb Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 22 Aug 2026 22:21:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EB=B3=B8=EB=AC=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=AA=A9=EB=A1=9D=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=C2=B7og:image=20=ED=8F=B4=EB=B0=B1=20=EC=A0=84=20?= =?UTF-8?q?=ED=8B=B0=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가 카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을 코어+3모듈+2템플릿에서 해소. - 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용) + seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인 - board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼, saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지), filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝 - ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정 - sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종) - 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정 --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 7 + INSTALL.md | 2 +- README.ko.md | 4 +- README.md | 4 +- .../Requests/Settings/SaveSettingsRequest.php | 17 ++ app/Seo/SeoMetaResolver.php | 14 + app/Services/SettingsService.php | 130 ++++++++- app/Support/HtmlImageExtractor.php | 183 ++++++++++++ config/app.php | 2 +- config/settings/defaults.json | 3 + docs/backend/api/settings.md | 1 + docs/extension/hooks.md | 8 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 6 + .../g7-core-ja/backend/ja/validation.php | 1 + .../_bundled/g7-core-ja/language-pack.json | 2 +- .../CHANGELOG.md | 6 + .../frontend/partial/admin.json | 2 + .../language-pack.json | 2 +- lang/en/validation.php | 1 + lang/ko/validation.php | 1 + modules/_bundled/sirsoft-board/CHANGELOG.md | 7 + modules/_bundled/sirsoft-board/composer.json | 2 +- ...ent_thumbnail_url_to_board_posts_table.php | 43 +++ .../_bundled/sirsoft-board/docs/api/board.md | 14 +- .../_bundled/sirsoft-board/docs/api/boards.md | 8 +- modules/_bundled/sirsoft-board/module.json | 4 +- .../_bundled/sirsoft-board/package-lock.json | 4 +- modules/_bundled/sirsoft-board/package.json | 2 +- .../src/Http/Resources/PostResource.php | 22 +- .../sirsoft-board/src/Models/Post.php | 50 +++- .../src/Repositories/PostRepository.php | 2 +- .../Upgrade/ContentThumbnailBackfillTest.php | 169 +++++++++++ .../User/PostListContentThumbnailTest.php | 264 ++++++++++++++++++ .../tests/Playwright/fixtures/board-auth.ts | 18 ++ .../user/board-content-thumbnail.spec.ts | 241 ++++++++++++++++ .../Unit/Models/PostContentThumbnailTest.php | 258 +++++++++++++++++ .../PostResourceThumbnailUrlTest.php | 92 ++++++ .../board-content-thumbnail-fallback.yaml | 48 ++++ .../sirsoft-board/upgrades/Upgrade_1_1_0.php | 17 ++ .../01_BackfillContentThumbnails.php | 206 ++++++++++++++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 10 + .../_bundled/sirsoft-ecommerce/composer.json | 2 +- ...mbnail_url_to_ecommerce_products_table.php | 44 +++ .../sirsoft-ecommerce/docs/api/categories.md | 2 +- .../sirsoft-ecommerce/docs/api/products.md | 20 +- .../sirsoft-ecommerce/docs/api/wishlist.md | 2 +- .../_bundled/sirsoft-ecommerce/module.json | 4 +- modules/_bundled/sirsoft-ecommerce/module.php | 6 +- .../sirsoft-ecommerce/package-lock.json | 4 +- .../_bundled/sirsoft-ecommerce/package.json | 2 +- .../PublicCategoryDetailResource.php | 6 +- .../sirsoft-ecommerce/src/Models/Product.php | 69 ++++- .../ProductListContentThumbnailTest.php | 226 +++++++++++++++ .../Feature/PublicCategoryThumbnailTest.php | 82 ++++++ .../ProductContentThumbnailBackfillTest.php | 151 ++++++++++ .../tests/Unit/EcommerceModuleSeoTest.php | 51 ++++ .../Models/ProductContentThumbnailTest.php | 206 ++++++++++++++ .../product-content-thumbnail-fallback.yaml | 53 ++++ .../upgrades/Upgrade_1_2_0.php | 17 ++ .../01_BackfillProductContentThumbnails.php | 238 ++++++++++++++++ .../sirsoft-ecommerce/vendor-bundle.json | 10 +- .../sirsoft-ecommerce/vendor-bundle.zip | Bin 435549 -> 435548 bytes modules/_bundled/sirsoft-page/CHANGELOG.md | 10 + modules/_bundled/sirsoft-page/composer.json | 2 +- ...d_content_thumbnail_url_to_pages_table.php | 44 +++ .../_bundled/sirsoft-page/docs/api/pages.md | 1 + modules/_bundled/sirsoft-page/module.json | 4 +- .../_bundled/sirsoft-page/package-lock.json | 4 +- modules/_bundled/sirsoft-page/package.json | 2 +- .../src/Http/Resources/PublicPageResource.php | 2 + .../_bundled/sirsoft-page/src/Models/Page.php | 68 ++++- .../PageContentThumbnailBackfillTest.php | 126 +++++++++ .../User/PageContentThumbnailResourceTest.php | 71 +++++ .../specs/user/page-og-image.spec.ts | 109 ++++++++ .../Unit/Models/PageContentThumbnailTest.php | 173 ++++++++++++ .../tests/scenarios/page-og-image.yaml | 42 +++ .../sirsoft-page/upgrades/Upgrade_1_1_0.php | 17 ++ .../01_BackfillPageContentThumbnails.php | 235 ++++++++++++++++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 6 + .../lang/partial/en/admin.json | 2 + .../lang/partial/ko/admin.json | 2 + .../partials/admin_settings/_tab_seo.json | 103 ++++++- .../sirsoft-admin_basic/package-lock.json | 4 +- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../sirsoft-admin_basic/template.json | 4 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 10 + ...earch-product-item-list-thumbnail.test.tsx | 144 ++++++++++ .../sirsoft-basic/layouts/page/show.json | 8 +- .../partials/search/products/_item_list.json | 18 +- .../_bundled/sirsoft-basic/package-lock.json | 4 +- templates/_bundled/sirsoft-basic/package.json | 2 +- .../_bundled/sirsoft-basic/template.json | 6 +- .../Settings/OgDefaultImageSettingTest.php | 126 +++++++++ tests/Unit/Seo/SeoMetaResolverTest.php | 84 ++++++ tests/Unit/Support/HtmlImageExtractorTest.php | 202 ++++++++++++++ tests/scenarios/seo-og-default-image.yaml | 28 ++ 98 files changed, 4640 insertions(+), 101 deletions(-) create mode 100644 app/Support/HtmlImageExtractor.php create mode 100644 modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml create mode 100644 modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php create mode 100644 modules/_bundled/sirsoft-board/upgrades/data/1.1.0/migrations/01_BackfillContentThumbnails.php create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.2.0/migrations/01_BackfillProductContentThumbnails.php create mode 100644 modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts create mode 100644 modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml create mode 100644 modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php create mode 100644 modules/_bundled/sirsoft-page/upgrades/data/1.1.0/migrations/01_BackfillPageContentThumbnails.php create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx create mode 100644 tests/Feature/Settings/OgDefaultImageSettingTest.php create mode 100644 tests/Unit/Support/HtmlImageExtractorTest.php create mode 100644 tests/scenarios/seo-og-default-image.yaml diff --git a/.env.example b/.env.example index 51bc68e1..e80ed460 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.8 +APP_VERSION=7.0.9 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 7b8d989f..ae970d1e 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.8 +APP_VERSION=7.0.9 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 04df1e86..9c7c5b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.9] - 2026-08-22 + +### Added + +- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.) +- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다. + ## [7.0.8] - 2026-08-22 ### Added diff --git a/INSTALL.md b/INSTALL.md index 2946a231..257047c5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -289,7 +289,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.8 g7 +# (필요 시) mv g7-7.0.9 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.ko.md b/README.ko.md index b0aa6a8d..a2f5d149 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React @@ -520,9 +520,9 @@ cp .env.example .env laelbe lyg-kaban bigmsg + abc101 hwaryeon1234 koojunho - abc101 yks118 kitrio movielee2020 diff --git a/README.md b/README.md index fdd76c39..705d3ebc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React @@ -534,9 +534,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped — laelbe lyg-kaban bigmsg + abc101 hwaryeon1234 koojunho - abc101 yks118 kitrio movielee2020 diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index 6ce8165a..12876fa3 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -97,6 +97,20 @@ class SaveSettingsRequest extends FormRequest ]); } + // 사이트 기본 OG 이미지도 동일하게 첨부 객체 배열 → 정수 ID 배열로 정규화 + $ogImageDefault = $this->input('seo.og_image_default'); + + if (is_array($ogImageDefault)) { + $this->merge([ + 'seo' => array_merge($this->input('seo', []), [ + 'og_image_default' => array_map( + fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item, + $ogImageDefault + ), + ]), + ]); + } + // GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음. // Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로 // boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장. @@ -243,6 +257,8 @@ class SaveSettingsRequest extends FormRequest 'seo.bot_detection_enabled' => ['nullable', 'boolean'], 'seo.bot_detection_library_enabled' => ['nullable', 'boolean'], 'seo.og_default_site_name' => ['nullable', 'string', 'max:200'], + 'seo.og_image_default' => ['nullable', 'array'], + 'seo.og_image_default.*' => ['integer', Rule::exists(Attachment::class, 'id')], 'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)], 'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)], 'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])], @@ -915,6 +931,7 @@ class SaveSettingsRequest extends FormRequest 'general.maintenance_mode' => __('validation.attributes.maintenance_mode'), 'general.asset_url_mode' => __('validation.attributes.asset_url_mode'), 'general.site_logo' => __('validation.attributes.site_logo'), + 'seo.og_image_default' => __('validation.attributes.og_image_default'), // mail 'mail.mailer' => __('validation.attributes.mailer'), 'mail.host' => __('validation.attributes.smtp_host'), diff --git a/app/Seo/SeoMetaResolver.php b/app/Seo/SeoMetaResolver.php index 678ccbc3..4c56b19c 100644 --- a/app/Seo/SeoMetaResolver.php +++ b/app/Seo/SeoMetaResolver.php @@ -4,6 +4,7 @@ namespace App\Seo; use App\Seo\Concerns\LocalizesSeoValues; use App\Seo\Concerns\SubstitutesSeoVariables; +use App\Services\SettingsService; class SeoMetaResolver { @@ -515,6 +516,19 @@ class SeoMetaResolver $title = $this->stripHtml($this->safeEval($og['title'] ?? '', $context)) ?: $fallbackTitle; $description = $this->stripHtml($this->safeEval($og['description'] ?? '', $context)) ?: $fallbackDescription; $image = $this->absoluteUrl($this->safeEval($og['image'] ?? '', $context)); + + // 사이트 기본 OG 이미지 폴백 (공개 이슈 #22) — 도메인 콘텐츠 폴백(본문 캐시)은 + // 각 모듈 레이아웃/훅 바인딩이 공급하므로 코어는 사이트 기본값 한 단만 담당한다. + // 체인: 레이아웃 선언(도메인 캐시 포함) → 사이트 기본 이미지 → 빈 값(태그 미출력). + // secure_url 파생은 반드시 이 폴백 적용 후의 $image 기준이어야 한다. + if ($image === '') { + $defaultImage = app(SettingsService::class)->getOgDefaultImageUrl(); + + if (is_string($defaultImage) && $defaultImage !== '') { + $image = $this->absoluteUrl($defaultImage); + } + } + $secureUrl = isset($og['image_secure_url']) ? $this->absoluteUrl($this->safeEval($og['image_secure_url'], $context)) : ($image !== '' && str_starts_with($image, 'https://') ? $image : ''); diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 80072b9d..c19904b7 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -147,6 +147,9 @@ class SettingsService // 첨부파일 정보 추가 (general 카테고리에) $settings['general']['site_logo'] = $this->getSiteLogoAttachment(); + // 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형) + $settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default'); + return $settings; } @@ -437,16 +440,30 @@ class SettingsService */ private function getSiteLogoAttachment(): array { - // JSON 설정에서 site_logo ID 배열 조회 - $siteLogoIds = $this->configRepository->get('general.site_logo', []); + return $this->getAttachmentListSetting('general.site_logo'); + } + + /** + * 첨부 ID 배열 설정을 첨부 목록(리소스 배열)으로 해석합니다. + * + * site_logo / og_image_default 처럼 "첨부 ID 배열 + 화면은 첨부 객체" 패턴의 + * 설정이 공유하는 로드 경로입니다. + * + * @param string $configKey 설정 키 (예: 'general.site_logo') + * @return array 첨부 목록 (없으면 빈 배열) + */ + private function getAttachmentListSetting(string $configKey): array + { + // JSON 설정에서 첨부 ID 배열 조회 + $ids = $this->configRepository->get($configKey, []); // 빈 배열이거나 배열이 아닌 경우 - if (empty($siteLogoIds) || ! is_array($siteLogoIds)) { + if (empty($ids) || ! is_array($ids)) { return []; } // ID 배열로 첨부파일 조회 (DB order 기준 정렬) - $attachments = $this->attachmentRepository->findByIds($siteLogoIds); + $attachments = $this->attachmentRepository->findByIds($ids); if ($attachments->isEmpty()) { return []; @@ -455,6 +472,21 @@ class SettingsService return $attachments->map(fn ($attachment) => (new AttachmentResource($attachment))->toListArray())->toArray(); } + /** + * 사이트 기본 OG 이미지 URL 을 반환합니다. + * + * SeoMetaResolver 가 레이아웃 og.image 선언(도메인 캐시 포함)이 빈 값일 때 + * 마지막 폴백으로 사용합니다 (공개 이슈 #22 — 사이트 기본 공유 이미지). + * + * @return string|null 첫 번째 이미지 첨부의 다운로드 URL (미설정 시 null) + */ + public function getOgDefaultImageUrl(): ?string + { + $ids = $this->configRepository->get('seo.og_image_default', []); + + return $this->getFirstImageUrl(is_array($ids) ? $ids : []); + } + /** * 첨부파일 ID 배열에서 첫 번째 이미지의 URL을 반환합니다. * @@ -530,6 +562,22 @@ class SettingsService $tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']); } + // 사이트 기본 OG 이미지도 site_logo 와 동형으로 처리 (공개 이슈 #22) + $removedOgImageIds = []; + + if ($tab === 'seo' && is_array($tabSettings['og_image_default'] ?? null)) { + $removedOgImageIds = $this->resolveRemovedAttachmentIds( + 'seo', + 'og_image_default', + $tabSettings['og_image_default'] + ); + + $tabSettings['og_image_default'] = $this->resolveSubmittedAttachmentIds( + 'og_image_default', + $tabSettings['og_image_default'] + ); + } + // 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지) $existingSettings = $this->configRepository->getCategory($tab); $mergedSettings = array_merge($existingSettings, $tabSettings); @@ -548,6 +596,7 @@ class SettingsService // 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조). $this->purgeSiteLogoAttachments($removedSiteLogoIds); + $this->purgeRemovedAttachments($removedOgImageIds, '기본 OG 이미지'); // SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다. // 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지 @@ -709,6 +758,18 @@ class SettingsService * @return array 저장할 첨부파일 ID 배열 */ private function resolveSiteLogoIds(array $submitted): array + { + return $this->resolveSubmittedAttachmentIds('site_logo', $submitted); + } + + /** + * 제출된 첨부 목록에서 해당 컬렉션에 실재하는 첨부 ID 만 남깁니다. + * + * @param string $collection 첨부 컬렉션명 (예: 'site_logo', 'og_image_default') + * @param array $submitted 제출값 (첨부 객체 배열 또는 ID 배열) + * @return array 저장할 첨부 ID 목록 + */ + private function resolveSubmittedAttachmentIds(string $collection, array $submitted): array { $submittedIds = $this->extractAttachmentIds($submitted); @@ -716,13 +777,72 @@ class SettingsService return []; } - $existingIds = $this->attachmentRepository->getByCollection('site_logo') + $existingIds = $this->attachmentRepository->getByCollection($collection) ->pluck('id') ->all(); return array_values(array_intersect($submittedIds, $existingIds)); } + /** + * 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 첨부 설정 ID 를 가려냅니다. + * + * 판정 기준·시점 규율은 resolveRemovedSiteLogoIds 와 동일합니다 — 직전 저장값에 + * 있었는데 이번 제출에서 빠진 id 만 파기 대상이며, 저장으로 값이 덮이기 전에 + * 판정해야 합니다. + * + * @param string $category 설정 카테고리 (예: 'seo') + * @param string $key 설정 키 (예: 'og_image_default') + * @param mixed $submitted 제출값 (미제출이면 null) + * @return array 파기 대상 첨부 ID 목록 + */ + private function resolveRemovedAttachmentIds(string $category, string $key, mixed $submitted): array + { + if (! is_array($submitted)) { + return []; + } + + $previousIds = $this->extractAttachmentIds( + $this->configRepository->getCategory($category)[$key] ?? [] + ); + + if ($previousIds === []) { + return []; + } + + $keptIds = $this->extractAttachmentIds($submitted); + + return array_values(array_diff($previousIds, $keptIds)); + } + + /** + * 제거가 확정된 설정 첨부를 파일까지 파기합니다 (저장 성공 후에만 호출). + * + * @param array $removedIds 파기 대상 첨부 ID 목록 + * @param string $label 로그 표기용 설정 이름 + */ + private function purgeRemovedAttachments(array $removedIds, string $label): void + { + if ($removedIds === []) { + return; + } + + foreach ($removedIds as $removedId) { + // 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면 + // 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다. + try { + $this->attachmentService->delete($removedId); + } catch (\Exception $e) { + Log::warning($label.' 첨부 파기 실패 — 저장은 확정됨', [ + 'attachment_id' => $removedId, + 'error' => $e->getMessage(), + ]); + } + } + + Log::info($label.' 첨부 제거', ['attachment_ids' => $removedIds]); + } + /** * 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다. * diff --git a/app/Support/HtmlImageExtractor.php b/app/Support/HtmlImageExtractor.php new file mode 100644 index 00000000..e92bae4e --- /dev/null +++ b/app/Support/HtmlImageExtractor.php @@ -0,0 +1,183 @@ +` 의 `src` 속성을 문서 순서로 수집합니다. + * + * ext-dom 부재·파싱 실패 시에는 안전측(빈 배열)으로 폴백합니다. + * + * @param string $html 본문 HTML + * @return array src 속성값 목록 (빈 값 제외, 문서 순서) + */ + public static function candidates(string $html): array + { + if (trim($html) === '') { + return []; + } + + if (! class_exists(\DOMDocument::class)) { + return []; + } + + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + + try { + $loaded = $document->loadHTML( + '

'.$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + + if (! $loaded) { + return []; + } + + $sources = []; + + foreach ($document->getElementsByTagName('img') as $img) { + if (! $img instanceof \DOMElement) { + continue; + } + + $src = trim($img->getAttribute('src')); + + if ($src !== '') { + $sources[] = $src; + } + } + + return $sources; + } catch (\Throwable $e) { + Log::warning('본문 이미지 추출 실패 — 후보 없음으로 폴백', [ + 'error' => $e->getMessage(), + ]); + + return []; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * 본문 HTML 에서 첫 번째 **내부** 이미지 URL 을 반환합니다. + + * 내부 판정 규칙 (후보를 문서 순서로 순회하며 첫 통과 값): + * 1. {@see TrustedScriptHosts::normalizeForOriginCheck()} 로 정규화 + * 2. 정규화 결과 `//` 시작(protocol-relative) → 외부로 제외, `/` 단일 시작 → 내부 + * 3. http(s) 절대 URL 은 host 가 `config('app.url')` host 와 동일(대소문자 무시)할 + * 때만 통과하고 path 부터의 상대 형태로 변환해 반환 (응답 절대화는 소비측 규약) + * 4. `$extraAllowedPrefixes` 중 하나로 시작(정규화 후 비교)하면 통과 — CDN 스토리지 + * 확장용이며 이 경우 원형 그대로 반환 + * 5. `data:` / `javascript:` 등 비 http(s)·비 path 스킴은 전부 제외 + * + * @param string $html 본문 HTML + * @param array $extraAllowedPrefixes 추가 허용 URL prefix 목록 + * @return string|null 첫 내부 이미지 URL (후보 없으면 null) + */ + public static function firstInternal(string $html, array $extraAllowedPrefixes = []): ?string + { + foreach (self::candidates($html) as $src) { + $resolved = self::resolveInternal($src, $extraAllowedPrefixes); + + if ($resolved === null) { + continue; + } + + // 컬럼 상한 초과 URL 은 캐시 불가 — 다음 후보로 넘어간다 + if (mb_strlen($resolved) > self::MAX_URL_LENGTH) { + continue; + } + + return $resolved; + } + + return null; + } + + /** + * 단일 src 후보를 내부 URL 로 해석합니다. + * + * @param string $src img src 속성값 + * @param array $extraAllowedPrefixes 추가 허용 URL prefix 목록 + * @return string|null 내부로 판정된 URL (외부/비허용 스킴이면 null) + */ + private static function resolveInternal(string $src, array $extraAllowedPrefixes): ?string + { + $trimmed = trim($src); + + if ($trimmed === '') { + return null; + } + + $normalized = TrustedScriptHosts::normalizeForOriginCheck($trimmed); + + if ($normalized === '') { + return null; + } + + // protocol-relative 는 외부 origin + if (str_starts_with($normalized, '//')) { + return null; + } + + // 단일 슬래시 시작 = same-origin 경로 + if (str_starts_with($normalized, '/')) { + return $normalized; + } + + $scheme = parse_url($normalized, PHP_URL_SCHEME); + + if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) { + $host = parse_url($normalized, PHP_URL_HOST); + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) { + $path = parse_url($normalized, PHP_URL_PATH); + $query = parse_url($normalized, PHP_URL_QUERY); + + return (is_string($path) && $path !== '' ? $path : '/') + .(is_string($query) && $query !== '' ? '?'.$query : ''); + } + } + + // 확장이 선언한 허용 prefix (CDN 직접 URL 스토리지 등) — 원형 유지 반환 + foreach ($extraAllowedPrefixes as $prefix) { + if (is_string($prefix) && $prefix !== '' && str_starts_with($normalized, $prefix)) { + return $trimmed; + } + } + + // 그 외 스킴(data:/javascript:/blob: 등)·미허용 host 전부 제외 + return null; + } +} diff --git a/config/app.php b/config/app.php index d1a68dcd..c6d64ca0 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.8'), + 'version' => env('APP_VERSION', '7.0.9'), /* |-------------------------------------------------------------------------- diff --git a/config/settings/defaults.json b/config/settings/defaults.json index dd001aef..7761070b 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -64,6 +64,7 @@ "bot_detection_enabled": true, "bot_detection_library_enabled": true, "og_default_site_name": "", + "og_image_default": [], "og_image_default_width": 1200, "og_image_default_height": 630, "twitter_default_card": "summary_large_image", @@ -225,6 +226,8 @@ "bot_detection_enabled": { "type": "boolean", "sensitive": false }, "bot_detection_library_enabled": { "type": "boolean", "sensitive": false }, "og_default_site_name": { "type": "string", "sensitive": false }, + "og_image_default": { "type": "array", "sensitive": false, "transform": "attachments" }, + "og_image_default_url": { "type": "string", "sensitive": false, "computed": true, "source": "og_image_default", "transform": "first_image_url" }, "og_image_default_width": { "type": "integer", "sensitive": false }, "og_image_default_height": { "type": "integer", "sensitive": false }, "twitter_default_card": { "type": "string", "sensitive": false }, diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index 33bae599..b71f9cdc 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -214,6 +214,7 @@ HTTP/1.1 200 | seo.bot_detection_enabled | body | boolean | 아니오 | — | 검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용) | | seo.bot_detection_library_enabled | body | boolean | 아니오 | — | 봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정) | | seo.og_default_site_name | body | string | 아니오 | max 200 | seo.og default site 이름 (식별자) | +| seo.og_image_default | body | array | 아니오 | 항목: 존재하는 첨부 ID | 사이트 기본 공유 이미지(og:image) 첨부 ID 배열 — 화면별 og:image 가 없을 때 폴백으로 사용. 화면 제출 형태(첨부 객체 배열)도 수용하며 정수 ID 배열로 정규화된다 (7.0.9+) | | seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 너비 (px) | | seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 높이 (px) | | seo.twitter_default_card | body | string | 아니오 | — | 기본 트위터 카드 유형 (summary 등) | diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index da3f4025..8965b663 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -133,6 +133,14 @@ core.activity_log.before_prune core.activity_log.after_prune core.notification_log.before_prune core.notification_log.after_prune core.schedule.before_prune_history core.schedule.after_prune_history +# 본문 첫 내부 이미지 썸네일 캐시 (Filter, 공개#22) — 각 모델 saving 이벤트가 발행. +# 값 = 추출된 첫 내부 이미지 URL(없으면 null), 인자 = (값, 모델, 전체 후보 src 배열). +# 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null 반환)할 수 있다. +# 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다. +sirsoft-board.post.filter_content_thumbnail +sirsoft-ecommerce.product.filter_content_thumbnail +sirsoft-page.page.filter_content_thumbnail + # 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴 # before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환. # 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션) diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index ed9e34e5..e4b0904f 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.8] - 2026-08-22 + +### Added + +- 사이트 기본 공유 이미지(og:image) 설정의 검증 속성 라벨 번역을 추가했습니다. + ## [1.0.7] - 2026-08-22 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 92ca8a3c..0d6f151b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -1129,6 +1129,7 @@ return [ 'maintenance_mode' => 'メンテナンスモード', 'asset_url_mode' => 'アセットURL方式', 'site_logo' => 'サイトロゴ', + 'og_image_default' => '既定の共有画像', 'bot_user_agents' => 'ボット User-Agent 一覧', 'bot_detection_enabled' => 'ボット検出の使用', 'bot_detection_library_enabled' => 'ボット検出ライブラリの使用', diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index e7ad4030..cb8bc8e2 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "scope": "core", "target_identifier": null, 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 1bc252fc..dab376af 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 @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.7] - 2026-08-22 + +### Added + +- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다. + ## [1.0.6] - 2026-08-22 ### Added 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 1fcd76de..d6435cd2 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 @@ -1423,6 +1423,8 @@ "og_image_default_width": "OG 画像 デフォルト横幅 (px)", "og_image_default_height": "OG 画像 デフォルト高さ (px)", "og_image_default_size_hint": "サムネイルの実際のサイズが不明な場合のデフォルト値です。Facebookは推奨 1200×630、最小 200×200。", + "og_image_default": "既定の共有画像", + "og_image_default_desc": "画面ごとの共有画像がない場合に使用するサイト既定の画像です。リンク共有時のプレビューカードに表示されます。", "twitter_default_card": "Twitterカード デフォルト タイプ", "twitter_default_card_hint": "大きい画像のカードは summary_large_image、小さいカードは summary を推奨します。", "twitter_default_site": "Twitter サイト ハンドル", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index a69f93ef..3d15faac 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/lang/en/validation.php b/lang/en/validation.php index 7e8e554e..47bb432f 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -1272,6 +1272,7 @@ return [ 'maintenance_mode' => 'maintenance mode', 'asset_url_mode' => 'asset URL mode', 'site_logo' => 'site logo', + 'og_image_default' => 'default share image', // SEO settings (additional) 'bot_user_agents' => 'bot user agents', 'bot_detection_enabled' => 'bot detection', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 78fa94a4..b62ccec4 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -1265,6 +1265,7 @@ return [ 'maintenance_mode' => '점검 모드', 'asset_url_mode' => '자산 주소 방식', 'site_logo' => '사이트 로고', + 'og_image_default' => '기본 공유 이미지', // SEO 설정 (추가) 'bot_user_agents' => '봇 User-Agent 목록', 'bot_detection_enabled' => '봇 감지 사용', diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 00d1a696..0d390800 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.0] - 2026-08-22 + +### Added + +- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) +- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다. + ## [1.0.5] - 2026-08-22 ### Security diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index 85fc5089..b426f515 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.0.5", + "version": "1.1.0", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php new file mode 100644 index 00000000..fec6752a --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php @@ -0,0 +1,43 @@ +string('content_thumbnail_url', 1000) + ->nullable() + ->after('content_mode') + ->comment('본문 첫 내부 이미지 URL 캐시 — 이미지 첨부가 없을 때 목록 썸네일 폴백'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (Schema::hasTable('board_posts')) { + $columns = Schema::getColumnListing('board_posts'); + + Schema::table('board_posts', function (Blueprint $table) use ($columns) { + if (in_array('content_thumbnail_url', $columns)) { + $table->dropColumn('content_thumbnail_url'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index 1d0c2f2c..97348780 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -529,7 +529,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 게시글 수정(PUT | reply_count | integer | `0` | 답변글 수 (집계) | | attachment_count | integer | `0` | 첨부파일 수 (집계) | | has_attachment | boolean | `false` | 첨부파일 보유 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 (없으면 null) | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | | is_reply | boolean | `false` | 답변글 여부 | @@ -880,7 +880,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1300,7 +1300,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1459,7 +1459,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1625,7 +1625,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 복원된 게시글. | reply_count | integer | `0` | 답변글 수 (집계) | | attachment_count | integer | `0` | 첨부파일 수 (집계) | | has_attachment | boolean | `false` | 첨부파일 보유 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) | | depth | integer | `0` | 계층 트리에서의 깊이 | | is_reply | boolean | `false` | 답변글 여부 | diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index a964edb6..113e8727 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -2848,7 +2848,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -3020,7 +3020,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시글 = `PostResource`, | reply_count | integer | `0` | 답변글 수 (집계) | | attachment_count | integer | `0` | 첨부 수 (집계) | | has_attachment | boolean | `false` | 첨부 보유 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL (첫 이미지 첨부 — 없으면 null) | +| thumbnail | null | `null` | 썸네일 이미지 URL — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | 답변글의 원글 식별자 (원글이면 null) | | depth | integer | `0` | 계층 트리 깊이 (0 = 원글) | | is_reply | boolean | `false` | 답변글 여부 | @@ -3438,7 +3438,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -3696,7 +3696,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 95d8b194..e2fa63ac 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,13 +5,13 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.5", + "version": "1.1.0", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.0.7", + "g7_version": ">=7.0.9", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index ba6f089e..4b0d6142 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.0.5", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-board", - "version": "1.0.5", + "version": "1.1.0", "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 44254293..ebf99bd7 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.0.5", + "version": "1.1.0", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php index c7951c3c..892f38e5 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php @@ -303,7 +303,8 @@ class PostResource extends BaseApiResource * 썸네일 URL을 관계 로딩 상태에 따라 반환합니다. * * thumbnailAttachment(경량 hasOne) 우선, attachments(전체) fallback. - * 둘 다 미로딩 시 null 반환 (lazy loading 방지). + * 첨부에서 URL 을 얻지 못한 모든 경우 본문 첫 내부 이미지 캐시 + * (content_thumbnail_url)로 폴백하고, 그것도 없으면 null (공개 이슈 #22). * * @return string|null 썸네일 URL */ @@ -312,28 +313,29 @@ class PostResource extends BaseApiResource // 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는 // 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894). // 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다. + // 본문 캐시 폴백도 반드시 이 게이트 뒤 — 에디터 이미지는 공개 hash 서빙이라 + // 첨부와 달리 서빙측 차단이 없어, 이 게이트가 유일한 차단선이다. if ($this->is_secret && ! $this->canViewSecretContent(request())) { return null; } + $url = null; + // 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지 if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) { $attachment = $this->thumbnailAttachment; $slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null); if ($slug && $attachment->hash) { - return $attachment->previewUrlForSlug($slug); + $url = $attachment->previewUrlForSlug($slug); } - - return null; + } elseif ($this->relationLoaded('attachments')) { + // 상세 페이지 등에서 attachments가 로딩된 경우 fallback + $url = $this->getThumbnailUrl(); } - // 상세 페이지 등에서 attachments가 로딩된 경우 fallback - if ($this->relationLoaded('attachments')) { - return $this->getThumbnailUrl(); - } - - return null; + // 이미지 첨부가 없을 때만 본문 첫 내부 이미지 캐시 폴백 (첨부 우선 정책) + return $url ?? ($this->content_thumbnail_url ?: null); } /** diff --git a/modules/_bundled/sirsoft-board/src/Models/Post.php b/modules/_bundled/sirsoft-board/src/Models/Post.php index 483f88f1..0887c563 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Post.php +++ b/modules/_bundled/sirsoft-board/src/Models/Post.php @@ -5,10 +5,12 @@ namespace Modules\Sirsoft\Board\Models; use App\Extension\HookManager; use App\Models\User; use App\Search\Contracts\FulltextSearchable; +use App\Support\HtmlImageExtractor; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Laravel\Scout\Searchable; use Modules\Sirsoft\Board\Enums\PostStatus; @@ -30,7 +32,7 @@ class Post extends Model implements FulltextSearchable 'content_mode' => ['label_key' => 'sirsoft-board::activity_log.fields.content_mode', 'type' => 'text'], 'is_notice' => ['label_key' => 'sirsoft-board::activity_log.fields.is_notice', 'type' => 'boolean'], 'is_secret' => ['label_key' => 'sirsoft-board::activity_log.fields.is_secret', 'type' => 'boolean'], - 'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\PostStatus::class], + 'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => PostStatus::class], ]; /** @@ -64,6 +66,7 @@ class Post extends Model implements FulltextSearchable 'title', 'content', 'content_mode', + 'content_thumbnail_url', 'user_id', 'author_name', 'password', @@ -105,6 +108,47 @@ class Post extends Model implements FulltextSearchable ]; } + /** + * 모델 이벤트 등록 + * + * 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 — + * 목록 쿼리는 content 를 SELECT 하지 않으므로(컬럼 프루닝) 조회 시점 파싱이 불가하다. + * Service 가 아닌 모델 saving 이벤트에 두는 이유: 시더·테스트의 Post::create() 직접 + * 호출과 이커머스 문의 훅 리스너 경유 경로까지 누락 없이 커버되는 유일 지점이기 때문. + */ + protected static function booted(): void + { + static::saving(function (Post $post) { + if ($post->exists && ! $post->isDirty('content') && ! $post->isDirty('content_mode')) { + return; + } + + // text 모드 본문은 이스케이프되어 렌더된다(상세에 이미지 미표시) — 리터럴 + // img 마크업을 캐시하면 목록 썸네일만 떠서 상세와 어긋나므로 추출하지 않는다. + if ($post->content_mode !== 'html') { + $post->content_thumbnail_url = null; + + return; + } + + $content = (string) $post->content; + + // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅. + // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다. + $value = HookManager::applyFilters( + 'sirsoft-board.post.filter_content_thumbnail', + HtmlImageExtractor::firstInternal($content), + $post, + HtmlImageExtractor::candidates($content) + ); + + // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등 + $post->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000 + ? $value + : null; + }); + } + /** * 게시판과의 관계를 정의합니다. * @@ -181,9 +225,9 @@ class Post extends Model implements FulltextSearchable * * 전체 attachments를 eager loading하는 대신 이미지 1건만 가져와 성능을 확보합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return HasOne */ - public function thumbnailAttachment(): \Illuminate\Database\Eloquent\Relations\HasOne + public function thumbnailAttachment(): HasOne { return $this->hasOne(Attachment::class, 'post_id') ->where('mime_type', 'like', 'image/%') diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index b4213362..cfc9d43b 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -62,7 +62,7 @@ class PostRepository implements PostRepositoryInterface // 목록 전용 컬럼: content(본문 HTML) 제외 → content_preview로 대체 $listColumns = [ 'id', 'board_id', 'user_id', 'parent_id', 'category', - 'title', 'author_name', 'content_mode', + 'title', 'author_name', 'content_mode', 'content_thumbnail_url', 'is_notice', 'is_secret', 'status', 'depth', 'view_count', 'comments_count', 'replies_count', 'attachments_count', 'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at', diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php new file mode 100644 index 00000000..31b39878 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php @@ -0,0 +1,169 @@ +run($context); + } + + /** + * @scenario backfill=legacy_row + * + * @effects backfill_fills_legacy_rows + */ + #[Test] + public function legacy_rows_are_backfilled_with_first_internal_image(): void + { + // createTestPost 는 DB 직접 insert — 모델 saving 이벤트를 타지 않아 + // 업그레이드 전 작성 글(캐시 null)과 동일한 상태다 + $legacy = $this->createTestPost([ + 'content_mode' => 'html', + 'content' => '

글

', + ]); + + $this->runBackfill(); + + $this->assertSame( + '/storage/uploads/legacy.jpg', + DB::table('board_posts')->where('id', $legacy)->value('content_thumbnail_url'), + '첫 외부 후보는 건너뛰고 첫 내부 이미지가 백필되어야 합니다.' + ); + } + + /** + * @scenario backfill=already_filled + * + * @effects backfill_fills_legacy_rows + */ + #[Test] + public function already_filled_rows_are_untouched(): void + { + $filled = $this->createTestPost([ + 'content' => '', + 'content_thumbnail_url' => '/storage/uploads/kept.jpg', + ]); + + $this->runBackfill(); + + $this->assertSame( + '/storage/uploads/kept.jpg', + DB::table('board_posts')->where('id', $filled)->value('content_thumbnail_url'), + '이미 값이 있는 행은 백필 대상이 아니어야 합니다.' + ); + } + + /** + * @scenario backfill=external_only + * + * @effects backfill_keeps_external_only_null + */ + #[Test] + public function external_only_rows_stay_null(): void + { + $external = $this->createTestPost([ + 'content_mode' => 'html', + 'content' => '', + ]); + $noImage = $this->createTestPost(['content_mode' => 'html', 'content' => '

이미지 없음

']); + + $this->runBackfill(); + + $this->assertNull(DB::table('board_posts')->where('id', $external)->value('content_thumbnail_url')); + $this->assertNull(DB::table('board_posts')->where('id', $noImage)->value('content_thumbnail_url')); + } + + /** + * text 모드 레거시 행은 리터럴 img 마크업이 있어도 백필되지 않아야 합니다. + * + * text 모드 본문은 이스케이프 렌더라 상세에 이미지가 표시되지 않는다 — + * 목록 썸네일만 뜨면 상세와 어긋난다 (content_mode 기본값이 text 라 모집단이 크다). + * + * @scenario backfill=text_mode + * + * @effects text_mode_content_never_caches + */ + #[Test] + public function text_mode_rows_are_not_backfilled(): void + { + $textMode = $this->createTestPost([ + 'content_mode' => 'text', + 'content' => '텍스트 본문의 마크업', + ]); + + $this->runBackfill(); + + $this->assertNull( + DB::table('board_posts')->where('id', $textMode)->value('content_thumbnail_url'), + 'text 모드 글은 백필 대상이 아니어야 합니다.' + ); + } + + /** + * @scenario backfill=idempotent + * + * @effects backfill_idempotent_second_run + */ + #[Test] + public function second_run_is_idempotent(): void + { + $this->createTestPost(['content_mode' => 'html', 'content' => '']); + $this->createTestPost(['content_mode' => 'html', 'content' => '']); + $this->createTestPost(['content_mode' => 'html', 'content' => '

이미지 없음

']); + + $this->runBackfill(); + $first = $this->snapshot(); + + $this->runBackfill(); + $second = $this->snapshot(); + + $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); + } + + /** + * 이 게시판 게시글의 (id, content_thumbnail_url) 스냅샷을 반환합니다. + * + * @return array 스냅샷 + */ + private function snapshot(): array + { + return DB::table('board_posts') + ->where('board_id', $this->board->id) + ->orderBy('id') + ->get(['id', 'content_thumbnail_url']) + ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url]) + ->all(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php new file mode 100644 index 00000000..eee904bd --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php @@ -0,0 +1,264 @@ + $slug, + 'name' => ['ko' => '본문 썸네일 게시판', 'en' => 'Content Thumbnail Board'], + 'is_active' => true, + 'secret_mode' => 'enabled', + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->grantUserRolePermissions(); + + $this->memberUser = User::factory()->create(['email' => 'thumb-member@test.com']); + $userRole = Role::where('identifier', 'user')->first(); + $this->memberUser->roles()->attach($userRole->id); + } + + /** + * 모델 이벤트를 경유해 게시글을 생성합니다 (saving 추출 경로 포함). + * + * @param array $attributes 덮어쓸 속성 + * @return Post 생성된 게시글 + */ + private function createPostViaModel(array $attributes = []): Post + { + return Post::create(array_merge([ + 'board_id' => $this->board->id, + 'title' => '본문 썸네일 글', + 'content' => '

본문

', + 'content_mode' => 'html', + 'author_name' => '테스트', + 'ip_address' => '127.0.0.1', + 'is_notice' => false, + 'is_secret' => false, + 'status' => 'published', + 'trigger_type' => 'admin', + ], $attributes)); + } + + /** + * 목록 응답에서 특정 게시글 행을 찾습니다. + * + * @param int $postId 게시글 ID + * @return array|null 목록 행 + */ + private function findListItem(int $postId): ?array + { + $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts"); + $response->assertStatus(200); + + foreach ($response->json('data.data') ?? [] as $item) { + if (($item['id'] ?? null) === $postId) { + return $item; + } + } + + return null; + } + + /** + * @scenario image_source=content_internal_only, secrecy=normal + * + * @effects content_internal_image_fills_list_thumbnail + */ + #[Test] + public function list_thumbnail_filled_from_content_image_without_attachment(): void + { + $post = $this->createPostViaModel([ + 'content' => '

글

', + ]); + + $item = $this->findListItem($post->id); + + $this->assertNotNull($item, '목록에 게시글이 있어야 합니다.'); + $this->assertSame('/storage/uploads/list-content.jpg', $item['thumbnail']); + } + + /** + * @scenario image_source=both, secrecy=normal + * + * @effects attachment_takes_precedence_over_content_image + */ + #[Test] + public function list_thumbnail_prefers_attachment_over_content_image(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $attachment = Attachment::create([ + 'board_id' => $this->board->id, + 'post_id' => $post->id, + 'original_filename' => 'attach.jpg', + 'stored_filename' => 'stored-attach.jpg', + 'disk' => 'modules', + 'path' => 'attachments/attach.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 100, + 'collection' => 'attachments', + 'order' => 0, + ]); + + $item = $this->findListItem($post->id); + + $this->assertNotNull($item); + $this->assertSame( + '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview', + $item['thumbnail'], + '이미지 첨부가 있으면 본문 캐시보다 첨부가 우선해야 합니다.' + ); + } + + /** + * @scenario image_source=content_internal_only, secrecy=secret_unauthorized + * + * @effects secret_post_thumbnail_stays_null + */ + #[Test] + public function secret_post_list_thumbnail_is_null_for_guest(): void + { + $post = $this->createPostViaModel([ + 'is_secret' => true, + 'content' => '', + ]); + + $item = $this->findListItem($post->id); + + $this->assertNotNull($item, '비밀글도 목록 행 자체는 노출됩니다 (제목/배지).'); + $this->assertNull($item['thumbnail'], '권한 없는 조회에서 본문 캐시 URL 이 노출되면 안 됩니다.'); + + // 응답 어디에도 이미지 URL 이 실리지 않아야 한다 (에디터 이미지는 공개 서빙) + $raw = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts")->getContent(); + $this->assertStringNotContainsString('secret-content.jpg', $raw); + } + + /** + * @scenario image_source=content_external_only, secrecy=normal + * + * @effects external_only_content_yields_null + */ + #[Test] + public function external_only_content_yields_null_thumbnail_in_list(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $item = $this->findListItem($post->id); + + $this->assertNotNull($item); + $this->assertNull($item['thumbnail']); + } + + /** + * @scenario image_source=attachment_only, secrecy=normal + * + * @effects attachment_takes_precedence_over_content_image + */ + #[Test] + public function attachment_only_post_keeps_existing_behavior(): void + { + $post = $this->createPostViaModel(['content' => '

첨부만 있는 글

']); + + $attachment = Attachment::create([ + 'board_id' => $this->board->id, + 'post_id' => $post->id, + 'original_filename' => 'only.jpg', + 'stored_filename' => 'stored-only.jpg', + 'disk' => 'modules', + 'path' => 'attachments/only.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 100, + 'collection' => 'attachments', + 'order' => 0, + ]); + + $item = $this->findListItem($post->id); + + $this->assertNotNull($item); + $this->assertSame( + '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview', + $item['thumbnail'], + ); + } + + /** + * 생성 응답의 thumbnail 이 본문 이미지로 즉시 채워져야 합니다. + * + * @effects detail_and_og_share_same_fallback + */ + #[Test] + public function create_response_thumbnail_is_immediately_filled(): void + { + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => '에디터 이미지 글', + 'content' => '

본문

', + 'content_mode' => 'html', + 'author_name' => '회원작성자', + ]); + + $response->assertStatus(201); + $this->assertSame('/storage/uploads/created.jpg', $response->json('data.thumbnail')); + } + + /** + * 수정 응답의 thumbnail 이 교체된 본문 이미지로 즉시 갱신되어야 합니다. + * + * @effects detail_and_og_share_same_fallback + */ + #[Test] + public function update_response_thumbnail_reflects_new_content(): void + { + $post = $this->createPostViaModel([ + 'user_id' => $this->memberUser->id, + 'trigger_type' => 'user', + 'content' => '', + ]); + + $response = $this->actingAs($this->memberUser) + ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$post->id}", [ + 'title' => '수정된 글', + 'content' => '', + 'content_mode' => 'html', + ]); + + $response->assertStatus(200); + $this->assertSame('/storage/uploads/after.jpg', $response->json('data.thumbnail')); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts index 733e0686..a816dc5e 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts @@ -29,6 +29,8 @@ type BoardAuthFixtures = { boardManageToken: string; /** 게시판 조회 + 첨부 다운로드 권한 토큰 (#413-58b 행위자 기록 검증용) */ attachmentDownloadToken: string; + /** 본문 썸네일 폴백 E2E 용 — 게시판 CRUD + 전용 게시판(e2e-content-thumbnail) 글/첨부 관리 토큰 (공개 #22) */ + contentThumbnailToken: string; }; export const test = base.extend({ @@ -89,6 +91,22 @@ export const test = base.extend({ ), ); }, + contentThumbnailToken: async ({}, use) => { + // 전용 카드형 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하고, + // 관리자 글 작성 + 첨부 업로드로 첨부 우선 정책까지 실측한다 (공개 #22). + await use( + issueToken( + 'sirsoft-board.boards.read', + 'sirsoft-board.boards.create', + 'sirsoft-board.boards.update', + 'sirsoft-board.boards.delete', + 'sirsoft-board.e2e-content-thumbnail.admin.posts.read', + 'sirsoft-board.e2e-content-thumbnail.admin.posts.write', + 'sirsoft-board.e2e-content-thumbnail.admin.manage', + 'sirsoft-board.e2e-content-thumbnail.admin.attachments.upload', + ), + ); + }, }); export { authenticatePage }; diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts new file mode 100644 index 00000000..efb93ff8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts @@ -0,0 +1,241 @@ +/** + * 카드형 목록 — 본문 첫 내부 이미지 썸네일 폴백 (공개 이슈 #22, 내부 #610). + * + * 에디터로 본문에만 이미지를 넣은 글(첨부 없음)이 카드형 목록에서 "이미지 없음" + * placeholder 대신 실제 썸네일을 렌더하는지, 이미지 첨부가 있으면 종전대로 첨부가 + * 우선하는지를 브라우저 수준에서 실측한다. 기대 이미지 URL 은 상수로 박제하지 않고 + * 그 글 상세 API 의 thumbnail 값을 읽어 단언한다 (운영자 편집 값 박제 금지 규율). + * + * 단위/통합: PostContentThumbnailTest(saving 추출), PostResourceThumbnailUrlTest(폴백 + * 우선순위·비밀글 게이트), PostListContentThumbnailTest(목록 API 계약)가 담당하고, + * 이 spec 은 카드 레이아웃 렌더(브라우저)를 담당한다. + * + * 전용 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하므로 시드 의존이 없다. + * + * @scenario board-content-thumbnail-fallback + * @axes image_source=content_internal_only image_source=both image_source=none board_type=card + * @effects content_internal_image_fills_list_thumbnail, + * attachment_takes_precedence_over_content_image, + * detail_and_og_share_same_fallback + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'e2e-content-thumbnail'; +const API = '/api/modules/sirsoft-board'; + +/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */ +async function api( + page: import('@playwright/test').Page, + bearer: string, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: any }> { + return page.evaluate( + async ({ bearer, method, path, body }) => { + const response = await fetch(path, { + method, + headers: { + Authorization: `Bearer ${bearer}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + /* 비 JSON 응답 허용 */ + } + return { status: response.status, body: parsed as any }; + }, + { bearer, method, path, body }, + ); +} + +async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise { + await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`); +} + +/** 전용 카드형 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */ +async function createCardBoard(page: import('@playwright/test').Page, bearer: string): Promise<{ status: number; body: any }> { + // board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다 + const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`); + const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0]; + + if (!managerUuid) { + throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`); + } + + return api(page, bearer, 'POST', `${API}/admin/boards`, { + slug: SLUG, + name: { ko: '[검수용] 본문썸네일', en: 'Content Thumbnail E2E' }, + type: 'card', + show_view_count: true, + use_report: false, + board_manager_ids: [managerUuid], + }); +} + +/** 관리자 글 작성 API 로 게시글을 생성하고 id 를 돌려준다 (모델 saving 추출 경로 경유). */ +async function createPost( + page: import('@playwright/test').Page, + bearer: string, + title: string, + content: string, +): Promise { + const created = await api(page, bearer, 'POST', `${API}/admin/board/${SLUG}/posts`, { + title, + content, + // text 모드는 이스케이프 렌더라 추출 게이트에 걸린다 — 에디터 시나리오는 html 모드 + content_mode: 'html', + author_name: 'E2E', + }); + if (![200, 201].includes(created.status)) { + throw new Error(`post create failed: ${created.status} ${JSON.stringify(created.body).slice(0, 300)}`); + } + return created.body?.data?.id; +} + +/** 1×1 픽셀 JPEG 를 브라우저에서 만들어 첨부로 업로드한다 (multipart). */ +async function uploadImageAttachment( + page: import('@playwright/test').Page, + bearer: string, + postId: number, +): Promise<{ status: number; body: any }> { + return page.evaluate( + async ({ bearer, postId, apiBase, slug }) => { + // canvas 로 진짜 JPEG 바이트를 생성 (mimes 검증 통과) + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#3b82f6'; + ctx.fillRect(0, 0, 8, 8); + const blob: Blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.9), + ); + + const form = new FormData(); + form.append('file', new File([blob], 'e2e-attach.jpg', { type: 'image/jpeg' })); + form.append('post_id', String(postId)); + + const response = await fetch(`${apiBase}/admin/board/${slug}/attachments`, { + method: 'POST', + headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }, + body: form, + }); + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + /* 비 JSON 응답 허용 */ + } + return { status: response.status, body: parsed as any }; + }, + { bearer, postId, apiBase: API, slug: SLUG }, + ); +} + +/** 사용자 상세 API 에서 thumbnail 값을 읽는다 (기대값의 SSoT — 상수 박제 금지). */ +async function fetchDetailThumbnail( + page: import('@playwright/test').Page, + bearer: string, + postId: number, +): Promise { + const detail = await api(page, bearer, 'GET', `${API}/boards/${SLUG}/posts/${postId}`); + expect(detail.status).toBe(200); + return detail.body?.data?.thumbnail ?? null; +} + +// 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정 +test.describe.configure({ mode: 'serial' }); + +test.describe('카드형 목록 본문 썸네일 폴백 (공개 #22)', () => { + // @scenario image_source=content_internal_only board_type=card + // @effects content_internal_image_fills_list_thumbnail, detail_and_og_share_same_fallback + test('첨부 없는 본문이미지 글의 카드에 썸네일이 렌더되고 placeholder 가 없다', async ({ page, contentThumbnailToken }) => { + await authenticatePage(page, contentThumbnailToken); + await page.goto('/'); + await deleteBoardIfExists(page, contentThumbnailToken); + + const created = await createCardBoard(page, contentThumbnailToken); + expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status); + + // 본문에만 내부 이미지 — 첨부 없음 (favicon 은 항상 서빙되는 내부 자산) + const postId = await createPost( + page, + contentThumbnailToken, + '[검수용] 본문 이미지만 있는 글', + '

본문입니다.

', + ); + + // 기대값은 상세 API 의 thumbnail (상수 박제 금지) + const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId); + expect(thumbnail, '상세 API thumbnail 이 본문 이미지로 채워져야 합니다').toBe('/favicon.ico'); + + // 카드형 목록 렌더 실측 + await page.goto(`/board/${SLUG}`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const card = page.locator('a, div').filter({ hasText: '[검수용] 본문 이미지만 있는 글' }).last(); + await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 }); + + // 그 카드에는 "이미지 없음" placeholder 가 없어야 한다 + await expect(card.getByText('이미지 없음')).toHaveCount(0); + }); + + // @scenario image_source=both board_type=card + // @effects attachment_takes_precedence_over_content_image + test('첨부와 본문이미지가 함께 있으면 첨부 preview 가 우선한다', async ({ page, contentThumbnailToken }) => { + await authenticatePage(page, contentThumbnailToken); + await page.goto('/'); + + // 본문 이미지 + 이미지 첨부 동시 보유 글 + const postId = await createPost( + page, + contentThumbnailToken, + '[검수용] 첨부 우선 글', + '

본문입니다.

', + ); + const uploaded = await uploadImageAttachment(page, contentThumbnailToken, postId); + expect([200, 201], `attachment upload failed: ${JSON.stringify(uploaded.body).slice(0, 300)}`).toContain(uploaded.status); + + const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId); + expect(thumbnail, '첨부가 있으면 첨부 preview URL 이어야 합니다').toContain('/attachment/'); + expect(thumbnail).toContain('/preview'); + + await page.goto(`/board/${SLUG}`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + // 카드에 첨부 preview 가 렌더된다 (본문 favicon 이 아니라) + await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 }); + }); + + // @scenario image_source=none board_type=card + // @effects content_internal_image_fills_list_thumbnail + test('이미지가 전혀 없는 글은 placeholder 가 유지된다 (회귀 없음) — 정리 포함', async ({ page, contentThumbnailToken }) => { + await authenticatePage(page, contentThumbnailToken); + await page.goto('/'); + + const postId = await createPost( + page, + contentThumbnailToken, + '[검수용] 이미지 없는 글', + '

이미지 없이 텍스트만 있는 본문입니다.

', + ); + + const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId); + expect(thumbnail).toBeNull(); + + await page.goto(`/board/${SLUG}`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + // 이미지 없는 글의 카드에는 placeholder("이미지 없음")가 렌더된다 + await expect(page.getByText('이미지 없음').first()).toBeVisible({ timeout: 15_000 }); + + // 생성물 정리 — 전용 게시판 삭제 (글·첨부 포함) + await deleteBoardIfExists(page, contentThumbnailToken); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php new file mode 100644 index 00000000..877a199a --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php @@ -0,0 +1,258 @@ + $this->board->id, + 'title' => '본문 썸네일 테스트', + 'content' => '

본문

', + 'content_mode' => 'html', + 'author_name' => '테스트', + 'ip_address' => '127.0.0.1', + 'is_notice' => false, + 'is_secret' => false, + 'status' => 'published', + 'trigger_type' => 'admin', + ], $attributes)); + } + + /** + * @scenario image_source=content_internal_only, secrecy=normal + * + * @effects content_internal_image_fills_list_thumbnail + */ + #[Test] + public function saving_extracts_first_internal_image_on_create(): void + { + $post = $this->createPostViaModel([ + 'content' => '

글

', + ]); + + $this->assertSame('/storage/uploads/first.jpg', $post->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=content_external_only, secrecy=normal + * + * @effects external_only_content_yields_null + */ + #[Test] + public function external_only_content_yields_null(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=none, secrecy=normal + * + * @effects external_only_content_yields_null + */ + #[Test] + public function content_without_image_yields_null(): void + { + $post = $this->createPostViaModel(['content' => '

이미지가 없는 본문

']); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } + + /** + * @effects recompute_on_content_change + */ + #[Test] + public function content_change_recomputes_cache(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $post->update(['content' => '']); + + $this->assertSame('/storage/uploads/new.jpg', $post->fresh()->content_thumbnail_url); + } + + /** + * @effects recompute_on_content_change + */ + #[Test] + public function removing_all_images_resets_cache_to_null(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $post->update(['content' => '

이미지 전부 제거됨

']); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } + + /** + * @effects no_recompute_on_unrelated_update + */ + #[Test] + public function unrelated_update_does_not_recompute(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + // 이벤트 우회로 캐시를 다른 값으로 바꿔 두면, content 무변경 저장이 + // 재계산을 하지 않는다는 사실이 값 보존으로 관측된다 + DB::table('board_posts')->where('id', $post->id) + ->update(['content_thumbnail_url' => '/storage/uploads/manual.jpg']); + + $post->fresh()->update(['title' => '제목만 변경']); + + $this->assertSame( + '/storage/uploads/manual.jpg', + $post->fresh()->content_thumbnail_url, + 'content 가 dirty 가 아니면 캐시를 재계산하지 않아야 합니다.' + ); + } + + /** + * @effects filter_hook_can_override_or_block + */ + #[Test] + public function filter_hook_can_override_value(): void + { + HookManager::addFilter(self::FILTER_HOOK, function ($value, $post, $candidates) { + $this->assertIsArray($candidates, '세 번째 인자로 전체 후보 목록이 전달되어야 합니다.'); + + return 'https://cdn.example.net/promoted.jpg'; + }); + + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $this->assertSame('https://cdn.example.net/promoted.jpg', $post->fresh()->content_thumbnail_url); + } + + /** + * @effects filter_hook_can_override_or_block + */ + #[Test] + public function filter_hook_can_block_fallback(): void + { + HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null); + + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } + + /** + * text 모드 글은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다. + * + * text 모드 본문은 이스케이프되어 렌더되므로(상세에 이미지가 표시되지 않음) + * 목록 썸네일만 뜨면 상세와 목록이 어긋난다 (Chrome MCP T10② 실측 회귀). + * + * @effects text_mode_content_never_caches + */ + #[Test] + public function text_mode_content_is_never_cached(): void + { + $post = $this->createPostViaModel([ + 'content_mode' => 'text', + 'content' => '텍스트 본문에 쓴 마크업', + ]); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } + + /** + * content_mode 를 html → text 로 바꾸면 캐시가 비워져야 합니다 (content 무변경이어도). + * + * @effects mode_switch_recomputes_cache + */ + #[Test] + public function switching_to_text_mode_clears_cache(): void + { + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $this->assertSame('/storage/uploads/a.jpg', $post->fresh()->content_thumbnail_url); + + $post->fresh()->update(['content_mode' => 'text']); + + $this->assertNull($post->fresh()->content_thumbnail_url, 'text 모드 전환 시 캐시가 비워져야 합니다.'); + } + + /** + * content_mode 를 text → html 로 바꾸면 캐시가 다시 계산되어야 합니다. + * + * @effects mode_switch_recomputes_cache + */ + #[Test] + public function switching_to_html_mode_recomputes_cache(): void + { + $post = $this->createPostViaModel([ + 'content_mode' => 'text', + 'content' => '', + ]); + + $this->assertNull($post->fresh()->content_thumbnail_url); + + $post->fresh()->update(['content_mode' => 'html']); + + $this->assertSame('/storage/uploads/promoted.jpg', $post->fresh()->content_thumbnail_url); + } + + /** + * 필터가 비정상 값(비문자열/상한 초과)을 돌려줘도 저장이 실패하지 않아야 합니다. + */ + #[Test] + public function filter_hook_invalid_return_degrades_to_null(): void + { + HookManager::addFilter(self::FILTER_HOOK, fn ($value) => str_repeat('a', 1500)); + + $post = $this->createPostViaModel([ + 'content' => '', + ]); + + $this->assertNull($post->fresh()->content_thumbnail_url); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php index d677fdd0..090ddb8b 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php @@ -112,4 +112,96 @@ class PostResourceThumbnailUrlTest extends BoardTestCase $response['thumbnail'], ); } + + // ── 본문 첫 내부 이미지 캐시 폴백 (공개 이슈 #22) ────────────── + + /** + * 이미지 첨부가 없으면 본문 캐시(content_thumbnail_url)로 폴백해야 합니다. + * + * @scenario image_source=content_internal_only, secrecy=normal + * + * @effects content_internal_image_fills_list_thumbnail + */ + #[Test] + public function thumbnail_falls_back_to_content_cache_without_attachment(): void + { + $postId = $this->createTestPost([ + 'content' => '', + 'content_thumbnail_url' => '/storage/uploads/content.jpg', + ]); + + $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertSame('/storage/uploads/content.jpg', $response['thumbnail']); + } + + /** + * 이미지 첨부와 본문 캐시가 모두 있으면 첨부가 우선해야 합니다 (첨부 우선 정책). + * + * @scenario image_source=both, secrecy=normal + * + * @effects attachment_takes_precedence_over_content_image + */ + #[Test] + public function attachment_takes_precedence_over_content_cache(): void + { + ['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg', [ + 'content_thumbnail_url' => '/storage/uploads/content.jpg', + ]); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertSame( + '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview', + $response['thumbnail'], + ); + } + + /** + * 비밀글은 본문 캐시가 있어도 썸네일이 null 이어야 합니다. + * + * 에디터 이미지는 공개 hash 서빙이라 첨부와 달리 서빙측 차단이 없다 — + * 이 게이트가 유일한 차단선이므로 폴백은 반드시 게이트 뒤에 있어야 한다. + * + * @scenario image_source=content_internal_only, secrecy=secret_unauthorized + * + * @effects secret_post_thumbnail_stays_null + */ + #[Test] + public function secret_post_thumbnail_null_even_with_content_cache(): void + { + $postId = $this->createTestPost([ + 'is_secret' => true, + 'content' => '', + 'content_thumbnail_url' => '/storage/uploads/secret.jpg', + ]); + + $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertArrayHasKey('thumbnail', $response); + $this->assertNull($response['thumbnail'], '비밀글은 본문 캐시 URL 도 방출되지 않아야 합니다.'); + } + + /** + * 첨부도 본문 캐시도 없으면 null 이어야 합니다. + * + * @scenario image_source=none, secrecy=normal + * + * @effects external_only_content_yields_null + */ + #[Test] + public function thumbnail_is_null_without_attachment_and_cache(): void + { + $postId = $this->createTestPost(['content' => '

이미지 없음

']); + + $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertNull($response['thumbnail']); + } } diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml new file mode 100644 index 00000000..46d259df --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml @@ -0,0 +1,48 @@ +feature: 본문 첫 이미지 목록 썸네일 폴백 + +description: | + 공개 이슈 #22 — 에디터로 본문에 삽입한 이미지가 카드형·갤러리형 목록 썸네일에 + 반영되지 않던 문제. 저장 시 본문 첫 내부 이미지 URL 을 board_posts.content_thumbnail_url + 에 캐시하고, 이미지 첨부가 없을 때 PostResource thumbnail 이 이를 폴백으로 사용한다. + 내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다. + +axes: + image_source: [attachment_only, content_internal_only, both, content_external_only, none] + secrecy: [normal, secret_unauthorized] + +exclusions: + - { image_source: attachment_only, secrecy: secret_unauthorized, reason: "비밀글 게이트는 image_source 와 무관하게 최상단에서 null — 대표 1조합(content_internal_only)으로 게이트를 검증하고 첨부 축은 기존 PostResourceThumbnailUrlTest 비밀글 케이스가 커버" } + - { image_source: both, secrecy: secret_unauthorized, reason: "동일 — 게이트 선행으로 소스 축 무의미, 대표 조합으로 검증" } + - { image_source: content_external_only, secrecy: secret_unauthorized, reason: "동일" } + - { image_source: none, secrecy: secret_unauthorized, reason: "동일" } + +sub_flows: + - id: backfill + description: 1.1.0 업그레이드 스텝이 기존 게시물을 chunkById 백필 + effects: + - backfill_fills_legacy_rows + - backfill_idempotent_second_run + - backfill_keeps_external_only_null + - id: recompute + description: content/content_mode 변경 시에만 saving 이벤트가 재계산 + effects: + - recompute_on_content_change + - no_recompute_on_unrelated_update + - filter_hook_can_override_or_block + - text_mode_content_never_caches + - mode_switch_recomputes_cache + +effects: + - attachment_takes_precedence_over_content_image + - content_internal_image_fills_list_thumbnail + - external_only_content_yields_null + - secret_post_thumbnail_stays_null + - detail_and_og_share_same_fallback + +test_files: + - tests/Unit/Support/HtmlImageExtractorTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php + - modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php + - modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts diff --git a/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php new file mode 100644 index 00000000..018ee4b3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php @@ -0,0 +1,17 @@ +logger->warning('[board:1.1.0] board_posts.content_thumbnail_url 미존재 — 스킵'); + + return; + } + + $filled = 0; + $noCandidate = 0; + + // text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상 + DB::table(self::POSTS_TABLE) + ->whereNull('content_thumbnail_url') + ->where('content_mode', 'html') + ->whereNotNull('content') + ->where('content', '!=', '') + ->orderBy('id') + ->select('id', 'content') + ->chunkById(200, function ($posts) use (&$filled, &$noCandidate) { + foreach ($posts as $post) { + $url = $this->firstInternalImageUrl((string) $post->content); + + if ($url === null) { + $noCandidate++; + + continue; + } + + DB::table(self::POSTS_TABLE) + ->where('id', $post->id) + ->update(['content_thumbnail_url' => $url]); + + $filled++; + } + }); + + $context->logger->info("[board:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}"); + } + + /** + * 본문 HTML 에서 첫 번째 내부 이미지 URL 을 추출합니다 (1.1.0 동결 사본). + * + * @param string $html 본문 HTML + * @return string|null 첫 내부 이미지 URL (없으면 null) + */ + private function firstInternalImageUrl(string $html): ?string + { + foreach ($this->imageSources($html) as $src) { + $resolved = $this->resolveInternal($src); + + if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) { + continue; + } + + return $resolved; + } + + return null; + } + + /** + * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본). + * + * @param string $html 본문 HTML + * @return array src 목록 + */ + private function imageSources(string $html): array + { + if (trim($html) === '' || ! class_exists(\DOMDocument::class)) { + return []; + } + + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + + try { + $loaded = $document->loadHTML( + '
' + .$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + + if (! $loaded) { + return []; + } + + $sources = []; + + foreach ($document->getElementsByTagName('img') as $img) { + if (! $img instanceof \DOMElement) { + continue; + } + + $src = trim($img->getAttribute('src')); + + if ($src !== '') { + $sources[] = $src; + } + } + + return $sources; + } catch (\Throwable) { + return []; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함). + * + * @param string $src img src 속성값 + * @return string|null 내부 URL (외부/비허용 스킴이면 null) + */ + private function resolveInternal(string $src): ?string + { + $trimmed = trim($src); + + if ($trimmed === '') { + return null; + } + + $normalized = $this->normalizeForOriginCheck($trimmed); + + if ($normalized === '' || str_starts_with($normalized, '//')) { + return null; + } + + if (str_starts_with($normalized, '/')) { + return $normalized; + } + + $scheme = parse_url($normalized, PHP_URL_SCHEME); + + if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) { + $host = parse_url($normalized, PHP_URL_HOST); + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) { + $path = parse_url($normalized, PHP_URL_PATH); + $query = parse_url($normalized, PHP_URL_QUERY); + + return (is_string($path) && $path !== '' ? $path : '/') + .(is_string($query) && $query !== '' ? '?'.$query : ''); + } + } + + return null; + } + + /** + * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본). + * + * @param string $url 원본 URL + * @return string 정규화된 URL + */ + private function normalizeForOriginCheck(string $url): string + { + $stripped = str_replace(["\t", "\n", "\r"], '', $url); + $slashed = str_replace('\\', '/', $stripped); + + return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index a7345482..932a7524 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.2.0] - 2026-08-22 + +### Added + +- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) + +### Fixed + +- 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다. + ## [1.1.2] - 2026-08-22 ### Security diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index ca4d7301..c1ca57bd 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php new file mode 100644 index 00000000..e054b782 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php @@ -0,0 +1,44 @@ +string('content_thumbnail_url', 1000) + ->nullable() + ->after('description_mode') + ->comment('설명 첫 내부 이미지 URL 캐시 — 상품 이미지가 없을 때 목록/공유 썸네일 폴백'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (Schema::hasTable('ecommerce_products')) { + $columns = Schema::getColumnListing('ecommerce_products'); + + Schema::table('ecommerce_products', function (Blueprint $table) use ($columns) { + if (in_array('content_thumbnail_url', $columns)) { + $table->dropColumn('content_thumbnail_url'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md index 11707de4..27763777 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md @@ -1369,6 +1369,6 @@ HTTP/1.1 200 -**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다. +**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다. 1.2.0 부터 `thumbnail_url` 필드(로드된 카테고리 이미지 첫 건의 URL, 없으면 `null`)가 추가되어 카테고리 페이지의 공유 미리보기 이미지(og:image)에 사용됩니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index 58f95aa8..c5e7888f 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -123,7 +123,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | | sku | string | `JK-0100` | 재고관리코드(SKU) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | | list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | @@ -413,7 +413,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201 | hs_code | string | `null` | HS 코드 (수출입 관세 분류 코드) | | images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | | thumbnail_hash | string | `null` | 대표 이미지 해시 | -| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | | meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | | meta_keywords | array | `null` | SEO 메타 키워드 배열 | @@ -1084,7 +1084,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 | hs_code | string | `null` | HS 코드 | | images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | | thumbnail_hash | string | `null` | 대표 이미지 해시 | -| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | | meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | | meta_keywords | array | `null` | SEO 메타 키워드 배열 | @@ -2011,7 +2011,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 | hs_code | string | `null` | HS 코드 | | images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | | thumbnail_hash | string | `null` | 대표 이미지 해시 | -| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | | meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | | meta_keywords | array | `null` | SEO 메타 키워드 배열 | @@ -2249,7 +2249,7 @@ _단건 응답: `data` 객체의 필드._ | seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | | barcode | null | `null` | 바코드 | | hs_code | null | `null` | HS 코드 (관세 분류) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | categories | array | `[{"id":29,"name":{"ko":"의류","en":"Clothing"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) | **응답 예시** @@ -2636,7 +2636,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | | sku | string | `JK-0100` | 재고관리코드(SKU) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | | list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | @@ -2766,7 +2766,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | | sku | string | `JK-0100` | 재고관리코드(SKU) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | | list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | @@ -2913,7 +2913,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | name_localized | string | `프리미엄 브이넥 티셔츠 #5` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | product_code | string | `4R5OG8VYO0JKG1WQ` | 상품코드 (상품 고유 관리 식별자) | | sku | string | `TS-0005` | 재고관리코드(SKU) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | integer | `34000` | 정가 (기본통화 자릿수로 정규화된 값) | | list_price_formatted | string | `34,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | selling_price | integer | `27000` | 판매가 (기본통화 자릿수로 정규화된 값) | @@ -3062,7 +3062,7 @@ _목록 응답: `data[]` 배열 항목의 필드 (`ProductListResource` — `GET | name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 | | product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | | sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) | | list_price_formatted | string | `112,594원` | `list_price` 의 표시용 통화 포맷 문자열 | | selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) | @@ -3186,7 +3186,7 @@ _단건 응답: `data` 객체의 필드._ | description_localized | string | `

부드러운 면 100% 손수건 3매 세트입니다.

` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | | images | array | `[{"id":7,"hash":"7df7761cdf16","original_filename":"produ…` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | -| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | meta_title | null | `null` | SEO 제목 (다국어 JSON) | | meta_description | object | `{"ko":"면 손수건 3매입 #1 의 직접 입력 SEO 설명입니다.","en":"Custom SEO …` | SEO 설명 (다국어 JSON) | | meta_keywords | null | `null` | SEO 키워드 (배열) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md index 49e72b9a..bb101382 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md @@ -59,7 +59,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | name_localized | string | `상품명` | 현재 로케일로 해석된 상품명 | | product_code | string | `P0000001` | 상품 코드 | | sku | string \| null | `SKU-001` | 재고 관리 코드 | -| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL | +| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) | | list_price | number | `30000` | 정가 (기본 통화 기준 반올림) | | list_price_formatted | string | `30,000원` | 정가 표시용 포맷 문자열 | | selling_price | number | `24000` | 판매가 (기본 통화 기준 반올림) | diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 02aab554..4d8c80fe 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,13 +5,13 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.7", + "g7_version": ">=7.0.9", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index d40f81f2..e418ae4f 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -2368,13 +2368,11 @@ class Module extends AbstractModule ? (str_starts_with($imageRaw, 'http') ? $imageRaw : url($imageRaw)) : ''; + // thumbnail_width/height 는 어떤 Resource 도 방출하지 않는 死키였다 — 값 공급이 + // 불가능한 축이므로 제거하고 크기 메타는 코어 기본값 폴백에 맡긴다 (공개 #22 부수 정리) return array_filter([ 'type' => 'product', 'image' => $image, - 'image_width' => isset($product['thumbnail_width']) && (int) $product['thumbnail_width'] > 0 - ? (int) $product['thumbnail_width'] : null, - 'image_height' => isset($product['thumbnail_height']) && (int) $product['thumbnail_height'] > 0 - ? (int) $product['thumbnail_height'] : null, 'image_alt' => $name, 'extra' => $extra, ], fn ($v) => $v !== null && $v !== '' && $v !== []); diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 7083fbfc..7ca37fa7 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.2", + "version": "1.2.0", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 105f9f6d..3ae99e0a 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.2", + "version": "1.2.0", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php index 71af32a6..ed94e496 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php @@ -15,7 +15,7 @@ class PublicCategoryDetailResource extends BaseApiResource /** * 리소스를 배열로 변환합니다. * - * @param Request $request 요청 + * @param Request $request 요청 * @return array */ public function toArray(Request $request): array @@ -31,6 +31,10 @@ class PublicCategoryDetailResource extends BaseApiResource 'parent_id' => $this->parent_id, 'products_count' => $this->products_count ?? 0, 'breadcrumb' => $this->getBreadcrumb(), + // 카테고리 og:image 생산자 — 로드된 images 첫 건의 URL (재쿼리 금지, + // 로드 여부는 Repository/Service 가 결정 — whenLoaded 단일 가드). + // module.php seoOpenGraph 의 category.data.thumbnail_url 소비처가 읽는다. + 'thumbnail_url' => $this->whenLoaded('images', fn () => $this->images->first()?->download_url, null), 'images' => $this->whenLoaded('images', function () { return $this->images->map(function ($image) { return [ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php index 642951ff..a6012d19 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Models; use App\Casts\AsUnicodeJson; use App\Extension\HookManager; use App\Search\Contracts\FulltextSearchable; +use App\Support\HtmlImageExtractor; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -93,6 +94,7 @@ class Product extends Model implements FulltextSearchable 'common_info_id', 'description', 'description_mode', + 'content_thumbnail_url', 'meta_title', 'meta_description', 'meta_keywords', @@ -133,6 +135,67 @@ class Product extends Model implements FulltextSearchable 'tax_status' => ProductTaxStatus::class, ]; + /** + * 모델 이벤트 등록 + * + * 설명 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 — + * 목록 쿼리는 description 을 SELECT 하지 않으므로 조회 시점 파싱이 불가하다. + * 시더·테스트의 Product::create() 직접 호출까지 커버되는 유일 지점이 모델 + * saving 이벤트다 (공개 이슈 #22 동종 — board 와 동형). + */ + protected static function booted(): void + { + static::saving(function (Product $product) { + if ($product->exists + && ! $product->isDirty('description') + && ! $product->isDirty('description_mode')) { + return; + } + + // text 모드 설명은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다 + if ($product->description_mode !== 'html') { + $product->content_thumbnail_url = null; + + return; + } + + // description 은 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로 + // 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용) + $extracted = null; + $candidates = []; + $description = $product->description; + $htmls = is_array($description) + ? array_values(array_filter( + [$description[config('app.locale')] ?? null, ...array_values($description)], + fn ($html) => is_string($html) && $html !== '' + )) + : (is_string($description) && $description !== '' ? [$description] : []); + + foreach ($htmls as $html) { + $candidates = array_merge($candidates, HtmlImageExtractor::candidates($html)); + $extracted ??= HtmlImageExtractor::firstInternal($html); + + if ($extracted !== null) { + break; + } + } + + // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅. + // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다. + $value = HookManager::applyFilters( + 'sirsoft-ecommerce.product.filter_content_thumbnail', + $extracted, + $product, + $candidates + ); + + // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등 + $product->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000 + ? $value + : null; + }); + } + /** * 이 상품의 옵션 관계 (color/size 등 가변 옵션 정의). * @@ -190,13 +253,15 @@ class Product extends Model implements FulltextSearchable $thumbnailImage = $this->images->firstWhere('is_thumbnail', true) ?? $this->images->first(); - return $thumbnailImage?->download_url; + // 상품 이미지가 없으면 설명 첫 내부 이미지 캐시로 폴백 (공개 이슈 #22 동종). + // 추가 쿼리 없음 — 이미 로드된 속성 읽기만 (쿼리 수 잠금 테스트 준수) + return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null); } $thumbnailImage = $this->images()->where('is_thumbnail', true)->first() ?? $this->images()->first(); - return $thumbnailImage?->download_url; + return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php new file mode 100644 index 00000000..833fa679 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php @@ -0,0 +1,226 @@ + 'ko']); + + // 비인증 공개 접근 (optional.sanctum + guest 권한) + $productReadPerm = Permission::firstOrCreate( + ['identifier' => 'sirsoft-ecommerce.user-products.read'], + ['name' => ['ko' => '상품 조회', 'en' => 'View Products'], 'type' => PermissionType::User] + ); + $guestRole = Role::firstOrCreate( + ['identifier' => 'guest'], + ['name' => ['ko' => '비회원', 'en' => 'Guest'], 'is_active' => true] + ); + $guestRole->permissions()->syncWithoutDetaching([$productReadPerm->id]); + PermissionMiddleware::clearGuestRoleCache(); + } + + /** + * @param array $attributes 덮어쓸 속성 + * @return Product 설명 이미지만 가진 상품 + */ + private function createContentImageProduct(array $attributes = []): Product + { + return Product::factory()->onSale()->create(array_merge([ + 'description_mode' => 'html', + 'description' => ['ko' => '

설명

'], + ], $attributes)); + } + + /** + * 목록 응답에서 특정 상품 행을 찾습니다. + * + * @param int $productId 상품 ID + * @return array|null 목록 행 + */ + private function findListItem(int $productId): ?array + { + $response = $this->getJson('/api/modules/sirsoft-ecommerce/products?per_page=50'); + $response->assertStatus(200); + + foreach ($response->json('data.data') ?? [] as $item) { + if (($item['id'] ?? null) === $productId) { + return $item; + } + } + + return null; + } + + /** + * @scenario image_source=content_internal_only, locale_content=default_locale + * + * @effects content_image_fills_product_thumbnail + */ + #[Test] + public function list_thumbnail_filled_from_description_without_product_image(): void + { + $product = $this->createContentImageProduct(); + + $item = $this->findListItem($product->id); + + $this->assertNotNull($item, '목록에 상품이 있어야 합니다.'); + $this->assertSame('/storage/products/desc-image.jpg', $item['thumbnail_url']); + } + + /** + * @scenario image_source=product_image_only, locale_content=default_locale + * + * @effects product_image_takes_precedence + */ + #[Test] + public function product_image_takes_precedence_over_description_cache(): void + { + $product = $this->createContentImageProduct(); + + $image = ProductImage::create([ + 'product_id' => $product->id, + 'original_filename' => 'main.jpg', + 'stored_filename' => 'main-'.uniqid().'.jpg', + 'disk' => 'modules', + 'path' => 'ecommerce/products/main.jpg', + 'mime_type' => 'image/jpeg', + 'file_size' => 1024, + 'is_thumbnail' => true, + 'sort_order' => 0, + 'collection' => 'main', + ]); + + $item = $this->findListItem($product->id); + + $this->assertNotNull($item); + $this->assertSame( + $image->download_url, + $item['thumbnail_url'], + '상품 이미지가 있으면 설명 캐시보다 우선해야 합니다.' + ); + } + + /** + * 상세(공개) 응답에도 같은 폴백이 반영되어야 합니다. + * + * @scenario image_source=both, locale_content=default_locale + * + * @effects detail_og_and_search_share_same_fallback + */ + #[Test] + public function public_detail_thumbnail_uses_same_fallback(): void + { + $product = $this->createContentImageProduct(); + + $response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->product_code); + + $response->assertStatus(200); + $this->assertSame('/storage/products/desc-image.jpg', $response->json('data.thumbnail_url')); + } + + /** + * 검색 표면(searchByKeyword)의 상품도 폴백된 썸네일에 도달해야 합니다. + * + * @effects detail_og_and_search_share_same_fallback + */ + #[Test] + public function search_surface_reaches_fallback_thumbnail(): void + { + $product = $this->createContentImageProduct(); + + // FULLTEXT 는 트랜잭션 내 신규 행을 보지 못한다 — 보조필드(product_code) 합집합 + // 매칭으로 같은 검색 쿼리 경로(관계 로드·게이트·컬럼 도달)를 검증한다 + $page = app(ProductService::class)->searchByKeyword($product->product_code); + $found = collect($page->items())->firstWhere('id', $product->id); + + $this->assertNotNull($found, '검색 결과에 상품이 있어야 합니다.'); + $this->assertSame('/storage/products/desc-image.jpg', $found->getThumbnailUrl()); + } + + /** + * 비공개 상품은 설명 캐시가 있어도 목록/검색에 노출되지 않아야 합니다 (게이트 불변). + * + * @scenario image_source=content_internal_only, locale_content=default_locale + * + * @effects hidden_product_stays_hidden + */ + #[Test] + public function hidden_product_is_not_exposed_by_fallback(): void + { + $hidden = $this->createContentImageProduct([ + 'display_status' => ProductDisplayStatus::HIDDEN, + 'name' => ['ko' => '비공개폴백상품', 'en' => 'HiddenFallbackProduct'], + ]); + + $this->assertNull($this->findListItem($hidden->id), '비공개 상품은 목록에 없어야 합니다.'); + + $page = app(ProductService::class)->searchByKeyword($hidden->product_code); + $this->assertNull( + collect($page->items())->firstWhere('id', $hidden->id), + '비공개 상품은 검색에도 없어야 합니다.' + ); + } + + /** + * @scenario image_source=content_external_only, locale_content=default_locale + * + * @effects external_only_description_yields_null + */ + #[Test] + public function external_only_description_yields_null_thumbnail(): void + { + $product = Product::factory()->onSale()->create([ + 'description_mode' => 'html', + 'description' => ['ko' => ''], + ]); + + $item = $this->findListItem($product->id); + + $this->assertNotNull($item); + $this->assertNull($item['thumbnail_url']); + } + + /** + * @scenario image_source=content_internal_only, locale_content=other_locale_only + * + * @effects other_locale_image_used_when_default_has_none + */ + #[Test] + public function other_locale_only_image_fills_thumbnail(): void + { + $product = Product::factory()->onSale()->create([ + 'description_mode' => 'html', + 'description' => [ + 'ko' => '

이미지 없는 한국어

', + 'en' => '', + ], + ]); + + $item = $this->findListItem($product->id); + + $this->assertNotNull($item); + $this->assertSame('/storage/products/en-image.jpg', $item['thumbnail_url']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php new file mode 100644 index 00000000..5959b4b0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php @@ -0,0 +1,82 @@ + ['ko' => '썸네일 카테고리', 'en' => 'Thumbnail Category'], + 'slug' => 'thumb-cat-'.uniqid(), + 'is_active' => true, + 'depth' => 0, + 'path' => '', + 'sort_order' => 0, + ], $attributes)); + } + + /** + * @effects category_detail_emits_thumbnail_url + */ + #[Test] + public function detail_emits_first_image_url_as_thumbnail(): void + { + $category = $this->createCategory(); + + $image = CategoryImage::create([ + 'category_id' => $category->id, + 'original_filename' => 'cat.jpg', + 'stored_filename' => 'cat-'.uniqid().'.jpg', + 'disk' => 'modules', + 'path' => 'category/thumb-test/cat.jpg', + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'sort_order' => 0, + ]); + + $response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug); + + $response->assertStatus(200); + $this->assertSame( + $image->download_url, + $response->json('data.thumbnail_url'), + '로드된 images 첫 건의 URL 이 thumbnail_url 로 방출되어야 합니다.' + ); + } + + /** + * @effects category_detail_emits_thumbnail_url + */ + #[Test] + public function detail_emits_null_thumbnail_without_images(): void + { + $category = $this->createCategory(); + + $response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug); + + $response->assertStatus(200); + $this->assertArrayHasKey('thumbnail_url', $response->json('data')); + $this->assertNull($response->json('data.thumbnail_url')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php new file mode 100644 index 00000000..8e86531b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php @@ -0,0 +1,151 @@ + 'ko']); + } + + /** + * 모델 이벤트를 우회해 레거시 상태 상품 행을 만듭니다 (업그레이드 전 등록분과 동일). + * + * @param array $attributes 덮어쓸 속성 + * @return int 상품 ID + */ + private function createLegacyRow(array $attributes = []): int + { + return DB::table('ecommerce_products')->insertGetId(array_merge([ + 'name' => json_encode(['ko' => '레거시 상품'], JSON_UNESCAPED_UNICODE), + 'product_code' => strtoupper(bin2hex(random_bytes(8))), + 'list_price' => 10000, + 'selling_price' => 10000, + 'stock_quantity' => 10, + 'safe_stock_quantity' => 1, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + 'tax_status' => 'taxable', + 'tax_rate' => 10, + 'description' => json_encode(['ko' => '

설명

'], JSON_UNESCAPED_UNICODE), + 'description_mode' => 'html', + 'content_thumbnail_url' => null, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } + + private function runBackfill(): void + { + $context = new UpgradeContext('1.1.2', '1.2.0', '1.2.0', 'extension-upgrade'); + + (new Upgrade_1_2_0)->run($context); + } + + /** + * @scenario backfill=legacy_row + * + * @effects backfill_fills_legacy_products + */ + #[Test] + public function legacy_rows_are_backfilled_from_multilingual_description(): void + { + $legacy = $this->createLegacyRow([ + 'description' => json_encode([ + 'ko' => '

글

', + 'en' => '', + ], JSON_UNESCAPED_UNICODE), + ]); + + $this->runBackfill(); + + $this->assertSame( + '/storage/products/legacy.jpg', + DB::table('ecommerce_products')->where('id', $legacy)->value('content_thumbnail_url'), + '기본 로케일의 첫 내부 이미지가 백필되어야 합니다 (외부 후보 건너뜀).' + ); + } + + /** + * @scenario backfill=text_mode + * + * @effects text_mode_description_never_caches + */ + #[Test] + public function text_mode_and_filled_and_external_rows_are_untouched(): void + { + $textMode = $this->createLegacyRow([ + 'description_mode' => 'text', + 'description' => json_encode(['ko' => '텍스트 '], JSON_UNESCAPED_UNICODE), + ]); + $filled = $this->createLegacyRow([ + 'description' => json_encode(['ko' => ''], JSON_UNESCAPED_UNICODE), + 'content_thumbnail_url' => '/storage/products/kept.jpg', + ]); + $external = $this->createLegacyRow([ + 'description' => json_encode(['ko' => ''], JSON_UNESCAPED_UNICODE), + ]); + + $this->runBackfill(); + + $this->assertNull(DB::table('ecommerce_products')->where('id', $textMode)->value('content_thumbnail_url')); + $this->assertSame('/storage/products/kept.jpg', DB::table('ecommerce_products')->where('id', $filled)->value('content_thumbnail_url')); + $this->assertNull(DB::table('ecommerce_products')->where('id', $external)->value('content_thumbnail_url')); + } + + /** + * @scenario backfill=idempotent + * + * @effects backfill_idempotent_second_run + */ + #[Test] + public function second_run_is_idempotent(): void + { + $this->createLegacyRow([ + 'description' => json_encode(['ko' => ''], JSON_UNESCAPED_UNICODE), + ]); + $this->createLegacyRow([ + 'description' => json_encode(['ko' => '

이미지 없음

'], JSON_UNESCAPED_UNICODE), + ]); + + $this->runBackfill(); + $first = $this->snapshot(); + + $this->runBackfill(); + $second = $this->snapshot(); + + $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); + } + + /** + * @return array 스냅샷 + */ + private function snapshot(): array + { + return DB::table('ecommerce_products') + ->orderBy('id') + ->get(['id', 'content_thumbnail_url']) + ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url]) + ->all(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php index f847d49b..2e4c72f4 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php @@ -69,6 +69,57 @@ class EcommerceModuleSeoTest extends ModuleTestCase $this->assertStringContainsString('/api/modules/sirsoft-ecommerce/product-image/abc123', $og['image']); } + /** + * 카테고리 og:image 출력 레벨 — thumbnail_url 이 있으면 image 가 절대 URL 로 방출된다. + * + * @scenario product-content-thumbnail-fallback + * + * @effects category_og_image_emitted_from_thumbnail_url + */ + public function test_category_seo_og_defaults_emits_image_from_thumbnail_url(): void + { + $context = [ + 'category' => [ + 'data' => [ + 'name' => ['ko' => '아우터', 'en' => 'Outer'], + 'thumbnail_url' => '/api/modules/sirsoft-ecommerce/category-image/cat123', + ], + ], + ]; + + $og = $this->module->seoOgDefaults('category', $context); + + $this->assertArrayHasKey('image', $og); + $this->assertStringStartsWith('http', $og['image'], 'og:image 는 절대 URL 이어야 공유 카드가 인식합니다'); + $this->assertStringContainsString('/api/modules/sirsoft-ecommerce/category-image/cat123', $og['image']); + $this->assertSame('아우터', $og['image_alt']); + } + + /** + * 카테고리 og:image 출력 레벨 — 이미지 없는 카테고리는 image 키 자체가 미출력된다 + * (빈 값은 array_filter 로 제거 → 코어 사이트 기본 OG 이미지 폴백에 위임). + * + * @scenario product-content-thumbnail-fallback + * + * @effects category_og_image_omitted_without_thumbnail + */ + public function test_category_seo_og_defaults_omits_image_without_thumbnail(): void + { + $context = [ + 'category' => [ + 'data' => [ + 'name' => '아우터', + 'thumbnail_url' => null, + ], + ], + ]; + + $og = $this->module->seoOgDefaults('category', $context); + + $this->assertArrayNotHasKey('image', $og); + $this->assertSame('website', $og['type']); + } + /** * 회귀: 상품 structured_data 도 다국어 array 안전 처리. */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php new file mode 100644 index 00000000..ce70a21a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php @@ -0,0 +1,206 @@ + 'ko']); + } + + protected function tearDown(): void + { + HookManager::clearFilter(self::FILTER_HOOK); + parent::tearDown(); + } + + /** + * @param array $attributes 덮어쓸 속성 + * @return Product 생성된 상품 + */ + private function createProduct(array $attributes = []): Product + { + return Product::factory()->create(array_merge([ + 'description_mode' => 'html', + ], $attributes)); + } + + /** + * @scenario image_source=content_internal_only, locale_content=default_locale + * + * @effects content_image_fills_product_thumbnail + */ + #[Test] + public function saving_extracts_first_internal_image_from_default_locale(): void + { + $product = $this->createProduct([ + 'description' => [ + 'ko' => '

설명

', + 'en' => '', + ], + ]); + + $this->assertSame('/storage/products/ko-first.jpg', $product->fresh()->content_thumbnail_url); + } + + /** + * 기본 로케일에 내부 이미지가 없으면 다른 로케일에서 찾아야 합니다. + * + * @scenario image_source=content_internal_only, locale_content=other_locale_only + * + * @effects other_locale_image_used_when_default_has_none + */ + #[Test] + public function falls_back_to_other_locale_when_default_has_no_internal_image(): void + { + $product = $this->createProduct([ + 'description' => [ + 'ko' => '

이미지 없는 한국어 설명

', + 'en' => '', + ], + ]); + + $this->assertSame('/storage/products/en-only.jpg', $product->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=content_external_only, locale_content=default_locale + * + * @effects external_only_description_yields_null + */ + #[Test] + public function external_only_description_yields_null(): void + { + $product = $this->createProduct([ + 'description' => [ + 'ko' => '', + ], + ]); + + $this->assertNull($product->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=none, locale_content=default_locale + * + * @effects external_only_description_yields_null + */ + #[Test] + public function description_without_image_yields_null(): void + { + $product = $this->createProduct([ + 'description' => ['ko' => '

이미지 없는 설명

'], + ]); + + $this->assertNull($product->fresh()->content_thumbnail_url); + } + + /** + * text 모드 설명은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다. + * + * @effects text_mode_description_never_caches + */ + #[Test] + public function text_mode_description_is_never_cached(): void + { + $product = $this->createProduct([ + 'description_mode' => 'text', + 'description' => ['ko' => '텍스트 설명의 마크업'], + ]); + + $this->assertNull($product->fresh()->content_thumbnail_url); + } + + /** + * @effects recompute_on_description_change + */ + #[Test] + public function description_change_recomputes_cache(): void + { + $product = $this->createProduct([ + 'description' => ['ko' => ''], + ]); + + $product->update(['description' => ['ko' => '']]); + + $this->assertSame('/storage/products/new.jpg', $product->fresh()->content_thumbnail_url); + } + + /** + * @effects no_recompute_on_unrelated_update + */ + #[Test] + public function unrelated_update_does_not_recompute(): void + { + $product = $this->createProduct([ + 'description' => ['ko' => ''], + ]); + + DB::table('ecommerce_products')->where('id', $product->id) + ->update(['content_thumbnail_url' => '/storage/products/manual.jpg']); + + $product->fresh()->update(['stock_quantity' => 7]); + + $this->assertSame('/storage/products/manual.jpg', $product->fresh()->content_thumbnail_url); + } + + /** + * @effects filter_hook_can_override_or_block + */ + #[Test] + public function filter_hook_can_override_or_block(): void + { + HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null); + + $blocked = $this->createProduct([ + 'description' => ['ko' => ''], + ]); + + $this->assertNull($blocked->fresh()->content_thumbnail_url); + + HookManager::clearFilter(self::FILTER_HOOK); + HookManager::addFilter(self::FILTER_HOOK, fn ($value) => 'https://cdn.example.net/promoted.jpg'); + + $promoted = $this->createProduct([ + 'description' => ['ko' => '

설명

'], + ]); + + $this->assertSame('https://cdn.example.net/promoted.jpg', $promoted->fresh()->content_thumbnail_url); + } + + /** + * 상품 이미지가 없으면 getThumbnailUrl 이 캐시로 폴백해야 합니다 (추가 쿼리 없이). + * + * @effects product_image_takes_precedence + */ + #[Test] + public function get_thumbnail_url_falls_back_to_cache_without_images(): void + { + $product = $this->createProduct([ + 'description' => ['ko' => ''], + ]); + + $loaded = Product::with('images')->findOrFail($product->id); + + $this->assertSame('/storage/products/cache.jpg', $loaded->getThumbnailUrl()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml new file mode 100644 index 00000000..81fa4938 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml @@ -0,0 +1,53 @@ +feature: 상품 설명 첫 이미지 목록/공유 썸네일 폴백 + +description: | + 공개 이슈 #22 동종 — 상품 이미지 없이 상세설명(에디터)에만 이미지를 넣은 상품이 + 목록·검색·og:image 6개 표면에서 이미지 없이 나가던 문제. 저장 시 설명(다국어 JSON) + 의 첫 내부 이미지 URL 을 ecommerce_products.content_thumbnail_url 에 캐시하고, + Product::getThumbnailUrl() 이 상품 이미지 부재 시 이를 폴백으로 사용한다. + 내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다. + +axes: + image_source: [product_image_only, content_internal_only, both, content_external_only, none] + locale_content: [default_locale, other_locale_only] + +exclusions: + - { image_source: product_image_only, locale_content: other_locale_only, reason: "로케일 축은 설명 내용에만 작용 — 상품 이미지 우선순위와 직교라 default_locale 대표 조합으로 검증" } + - { image_source: both, locale_content: other_locale_only, reason: "동일 — 로케일 축은 추출 입력에만 작용, 우선순위 판정과 직교" } + - { image_source: content_external_only, locale_content: other_locale_only, reason: "동일 — 외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null)" } + - { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" } + +sub_flows: + - id: backfill + description: 1.2.0 업그레이드 스텝이 기존 상품을 chunkById 백필 (다국어 JSON 파싱 동결 사본) + effects: + - backfill_fills_legacy_products + - backfill_idempotent_second_run + - text_mode_description_never_caches + - id: recompute + description: description/description_mode 변경 시에만 saving 이벤트가 재계산 + effects: + - recompute_on_description_change + - no_recompute_on_unrelated_update + - filter_hook_can_override_or_block + - id: category_og_producer + description: 死표현식이던 category.data.thumbnail_url 의 생산자 신설 (P3-B — 로드된 images 첫 건) + effects: + - category_detail_emits_thumbnail_url + - category_og_image_emitted_from_thumbnail_url + - category_og_image_omitted_without_thumbnail + +effects: + - product_image_takes_precedence + - content_image_fills_product_thumbnail + - other_locale_image_used_when_default_has_none + - external_only_description_yields_null + - hidden_product_stays_hidden + - detail_og_and_search_share_same_fallback + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php new file mode 100644 index 00000000..132c4338 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php @@ -0,0 +1,17 @@ +logger->warning('[ecommerce:1.2.0] ecommerce_products.content_thumbnail_url 미존재 — 스킵'); + + return; + } + + $filled = 0; + $noCandidate = 0; + + // text 모드 설명은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상 + DB::table(self::PRODUCTS_TABLE) + ->whereNull('content_thumbnail_url') + ->where('description_mode', 'html') + ->whereNotNull('description') + ->where('description', '!=', '') + ->orderBy('id') + ->select('id', 'description') + ->chunkById(200, function ($products) use (&$filled, &$noCandidate) { + foreach ($products as $product) { + $url = $this->firstInternalFromDescription((string) $product->description); + + if ($url === null) { + $noCandidate++; + + continue; + } + + DB::table(self::PRODUCTS_TABLE) + ->where('id', $product->id) + ->update(['content_thumbnail_url' => $url]); + + $filled++; + } + }); + + $context->logger->info("[ecommerce:1.2.0] 상품 설명 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}"); + } + + /** + * 다국어 JSON(또는 레거시 평문) 설명에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본). + * + * 기본 로케일 값 우선, 없으면 배열 순서대로 첫 내부 이미지가 나올 때까지 시도한다. + * + * @param string $rawDescription DB 원시 description 값 + * @return string|null 첫 내부 이미지 URL (없으면 null) + */ + private function firstInternalFromDescription(string $rawDescription): ?string + { + if (trim($rawDescription) === '') { + return null; + } + + $decoded = json_decode($rawDescription, true); + + $htmls = is_array($decoded) + ? array_values(array_filter( + [$decoded[config('app.locale')] ?? null, ...array_values($decoded)], + static fn ($html) => is_string($html) && $html !== '' + )) + : [$rawDescription]; + + foreach ($htmls as $html) { + $url = $this->firstInternalImageUrl($html); + + if ($url !== null) { + return $url; + } + } + + return null; + } + + /** + * 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본). + * + * @param string $html 본문 HTML + * @return string|null 첫 내부 이미지 URL + */ + private function firstInternalImageUrl(string $html): ?string + { + foreach ($this->imageSources($html) as $src) { + $resolved = $this->resolveInternal($src); + + if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) { + continue; + } + + return $resolved; + } + + return null; + } + + /** + * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본). + * + * @param string $html 본문 HTML + * @return array src 목록 + */ + private function imageSources(string $html): array + { + if (trim($html) === '' || ! class_exists(\DOMDocument::class)) { + return []; + } + + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + + try { + $loaded = $document->loadHTML( + '
' + .$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + + if (! $loaded) { + return []; + } + + $sources = []; + + foreach ($document->getElementsByTagName('img') as $img) { + if (! $img instanceof \DOMElement) { + continue; + } + + $src = trim($img->getAttribute('src')); + + if ($src !== '') { + $sources[] = $src; + } + } + + return $sources; + } catch (\Throwable) { + return []; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함). + * + * @param string $src img src 속성값 + * @return string|null 내부 URL (외부/비허용 스킴이면 null) + */ + private function resolveInternal(string $src): ?string + { + $trimmed = trim($src); + + if ($trimmed === '') { + return null; + } + + $normalized = $this->normalizeForOriginCheck($trimmed); + + if ($normalized === '' || str_starts_with($normalized, '//')) { + return null; + } + + if (str_starts_with($normalized, '/')) { + return $normalized; + } + + $scheme = parse_url($normalized, PHP_URL_SCHEME); + + if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) { + $host = parse_url($normalized, PHP_URL_HOST); + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) { + $path = parse_url($normalized, PHP_URL_PATH); + $query = parse_url($normalized, PHP_URL_QUERY); + + return (is_string($path) && $path !== '' ? $path : '/') + .(is_string($query) && $query !== '' ? '?'.$query : ''); + } + } + + return null; + } + + /** + * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본). + * + * @param string $url 원본 URL + * @return string 정규화된 URL + */ + private function normalizeForOriginCheck(string $url): string + { + $stripped = str_replace(["\t", "\n", "\r"], '', $url); + $slashed = str_replace('\\', '/', $stripped); + + return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index 419a3927..3fce4c60 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,15 +1,15 @@ { "schema_version": "1.0", - "generated_at": "2026-08-21T13:47:13+00:00", + "generated_at": "2026-08-22T12:22:17+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", - "composer_json_sha256": "6dc16ed7acb614593e17d97cf3230af0935e3e4910000026b4a7062d4f9de031", + "composer_json_sha256": "0556835aa543c462efe9132123b57234fddb66e482898144e7930f6cbedb1a9d", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "b1f6cfcb3fea1c68be7ffd55074acab8c2d51c2750e9c036a678abc40ebee405", - "zip_size": 435549, + "zip_sha256": "3127bf97f4342c91cffc0d729eaa70f2a1f74a4a4015288081a082e665ccb9c0", + "zip_size": 435548, "package_count": 1, "php_requirement": "^8.2", - "g7_version": "7.0.8", + "g7_version": "7.0.9", "packages": [ { "name": "ezyang/htmlpurifier", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index 3d732ca64e2651613a1fc772d1780fb424c06c1c..0a4034344bb108a27c296630c44eb327fede01c8 100644 GIT binary patch delta 8573 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 delta 8561 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 diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index aef17cfa..6962e78e 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.0] - 2026-08-22 + +### Added + +- 페이지를 공유할 때의 미리보기 이미지(og:image)가 본문의 첫 이미지로 채워집니다. 이전에는 페이지에 공유 이미지를 공급할 방법이 전혀 없어 미리보기 카드가 항상 이미지 없이 표시되었습니다. 외부 주소의 이미지는 사용하지 않으며, 기존 페이지는 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) + +### Fixed + +- 페이지 공유 미리보기의 설명(og:description)이 항상 비어 있던 문제를 수정했습니다. 이제 페이지 SEO 설정의 설명이 사용됩니다. + ## [1.0.3] - 2026-08-19 ### Security diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json index 0f070e06..b7e3aed3 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.0.3", + "version": "1.1.0", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php b/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php new file mode 100644 index 00000000..65cf6917 --- /dev/null +++ b/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php @@ -0,0 +1,44 @@ +string('content_thumbnail_url', 1000) + ->nullable() + ->after('content_mode') + ->comment('본문 첫 내부 이미지 URL 캐시 — 페이지 og:image 폴백'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (Schema::hasTable('pages')) { + $columns = Schema::getColumnListing('pages'); + + Schema::table('pages', function (Blueprint $table) use ($columns) { + if (in_array('content_thumbnail_url', $columns)) { + $table->dropColumn('content_thumbnail_url'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md index d8dcd6ad..7d172551 100644 --- a/modules/_bundled/sirsoft-page/docs/api/pages.md +++ b/modules/_bundled/sirsoft-page/docs/api/pages.md @@ -1271,6 +1271,7 @@ _단건 응답: `data` 객체의 필드._ | title | string | `이용약관` | 제목 | | content | string | `

=7.0.7", + "g7_version": ">=7.0.9", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json index d29325a4..a786b89a 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.0.3", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-page", - "version": "1.0.3", + "version": "1.1.0", "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 bdc4a069..74bea862 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.0.3", + "version": "1.1.0", "description": "그누보드7 페이지 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php index be2e611c..39f6658d 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php @@ -53,6 +53,8 @@ class PublicPageResource extends BaseApiResource ? ($this->content[$locale] ?? $this->content[$fallback] ?? (! empty($this->content) ? array_values($this->content)[0] : '')) : (string) ($this->content ?? ''), 'content_mode' => $this->content_mode ?? 'html', + // 본문 첫 내부 이미지 URL 캐시 — 페이지 표시 레이아웃의 og:image 가 소비 (공개 #22) + 'content_thumbnail_url' => $this->content_thumbnail_url, 'is_preview' => $this->preview, 'published_at' => $this->published_at ? $this->formatDateTimeStringForUser($this->published_at) diff --git a/modules/_bundled/sirsoft-page/src/Models/Page.php b/modules/_bundled/sirsoft-page/src/Models/Page.php index f00a4d5b..9fa2d960 100644 --- a/modules/_bundled/sirsoft-page/src/Models/Page.php +++ b/modules/_bundled/sirsoft-page/src/Models/Page.php @@ -2,10 +2,12 @@ namespace Modules\Sirsoft\Page\Models; +use App\Casts\AsUnicodeJson; use App\Extension\HookManager; use App\Models\User; -use App\Casts\AsUnicodeJson; use App\Search\Contracts\FulltextSearchable; +use App\Support\HtmlImageExtractor; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -52,6 +54,7 @@ class Page extends Model implements FulltextSearchable 'title', 'content', 'content_mode', + 'content_thumbnail_url', 'published', 'published_at', 'seo_meta', @@ -77,6 +80,65 @@ class Page extends Model implements FulltextSearchable ]; } + /** + * 모델 이벤트 등록 + * + * 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다. + * 모델 saving 이벤트에 두는 이유: PageService::restoreVersion() 이 서비스 + * create/update 를 우회해 Repository 를 직접 호출하므로, 버전 롤백 시에도 캐시가 + * 자동 재계산되는 유일 지점이 모델 이벤트다 (공개 이슈 #22 동종 — board 와 동형). + */ + protected static function booted(): void + { + static::saving(function (Page $page) { + if ($page->exists && ! $page->isDirty('content') && ! $page->isDirty('content_mode')) { + return; + } + + // text 모드 본문은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다 + if ($page->content_mode !== 'html') { + $page->content_thumbnail_url = null; + + return; + } + + // content 는 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로 + // 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용) + $extracted = null; + $candidates = []; + $content = $page->content; + $htmls = is_array($content) + ? array_values(array_filter( + [$content[config('app.locale')] ?? null, ...array_values($content)], + fn ($html) => is_string($html) && $html !== '' + )) + : (is_string($content) && $content !== '' ? [$content] : []); + + foreach ($htmls as $html) { + $candidates = array_merge($candidates, HtmlImageExtractor::candidates($html)); + $extracted ??= HtmlImageExtractor::firstInternal($html); + + if ($extracted !== null) { + break; + } + } + + // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅. + // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다. + $value = HookManager::applyFilters( + 'sirsoft-page.page.filter_content_thumbnail', + $extracted, + $page, + $candidates + ); + + // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등 + $page->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000 + ? $value + : null; + }); + } + /** * 생성자와의 관계를 정의합니다. * @@ -120,8 +182,8 @@ class Page extends Model implements FulltextSearchable /** * 발행된 페이지만 조회하는 스코프 * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ public function scopePublished($query) { diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php new file mode 100644 index 00000000..9a817cbe --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php @@ -0,0 +1,126 @@ + 'ko']); + } + + protected function tearDown(): void + { + DB::table('pages')->where('slug', 'like', 'backfill-%')->delete(); + parent::tearDown(); + } + + /** + * 모델 이벤트를 우회해 레거시 상태 페이지 행을 만듭니다. + * + * @param array $attributes 덮어쓸 속성 + * @return int 페이지 ID + */ + private function createLegacyRow(array $attributes = []): int + { + return DB::table('pages')->insertGetId(array_merge([ + 'slug' => 'backfill-'.uniqid(), + 'title' => json_encode(['ko' => '레거시 페이지'], JSON_UNESCAPED_UNICODE), + 'content' => json_encode(['ko' => '

본문

'], JSON_UNESCAPED_UNICODE), + 'content_mode' => 'html', + 'content_thumbnail_url' => null, + 'published' => 1, + 'published_at' => now(), + 'current_version' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } + + private function runBackfill(): void + { + $context = new UpgradeContext('1.0.3', '1.1.0', '1.1.0', 'extension-upgrade'); + + (new Upgrade_1_1_0)->run($context); + } + + /** + * @scenario backfill=legacy_row + * + * @effects backfill_fills_legacy_pages + */ + #[Test] + public function legacy_rows_are_backfilled_and_gated(): void + { + $legacy = $this->createLegacyRow([ + 'content' => json_encode([ + 'ko' => '', + ], JSON_UNESCAPED_UNICODE), + ]); + $textMode = $this->createLegacyRow([ + 'content_mode' => 'text', + 'content' => json_encode(['ko' => '텍스트 '], JSON_UNESCAPED_UNICODE), + ]); + $filled = $this->createLegacyRow([ + 'content' => json_encode(['ko' => ''], JSON_UNESCAPED_UNICODE), + 'content_thumbnail_url' => '/storage/pages/kept.jpg', + ]); + + $this->runBackfill(); + + $this->assertSame('/storage/pages/legacy.jpg', DB::table('pages')->where('id', $legacy)->value('content_thumbnail_url')); + $this->assertNull(DB::table('pages')->where('id', $textMode)->value('content_thumbnail_url')); + $this->assertSame('/storage/pages/kept.jpg', DB::table('pages')->where('id', $filled)->value('content_thumbnail_url')); + } + + /** + * @scenario backfill=idempotent + * + * @effects backfill_idempotent_second_run + */ + #[Test] + public function second_run_is_idempotent(): void + { + $this->createLegacyRow([ + 'content' => json_encode(['ko' => ''], JSON_UNESCAPED_UNICODE), + ]); + $this->createLegacyRow([ + 'content' => json_encode(['ko' => '

이미지 없음

'], JSON_UNESCAPED_UNICODE), + ]); + + $this->runBackfill(); + $first = $this->snapshot(); + + $this->runBackfill(); + $second = $this->snapshot(); + + $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); + } + + /** + * @return array 스냅샷 + */ + private function snapshot(): array + { + return DB::table('pages') + ->where('slug', 'like', 'backfill-%') + ->orderBy('id') + ->get(['id', 'content_thumbnail_url']) + ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url]) + ->all(); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php b/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php new file mode 100644 index 00000000..b9c53d6b --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php @@ -0,0 +1,71 @@ +forceDelete(); + parent::tearDown(); + } + + /** + * @scenario image_source=content_internal_only, locale_content=default_locale + * + * @effects public_resource_emits_thumbnail_key + */ + #[Test] + public function public_response_emits_content_thumbnail_url(): void + { + config(['app.locale' => 'ko']); + + Page::factory()->create([ + 'slug' => 'thumb-emit-filled', + 'published' => true, + 'published_at' => now(), + 'content' => ['ko' => '

본문

'], + ]); + + $response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-filled'); + + $response->assertStatus(200); + $this->assertSame('/storage/pages/og-image.jpg', $response->json('data.content_thumbnail_url')); + } + + /** + * @scenario image_source=none, locale_content=default_locale + * + * @effects public_resource_emits_thumbnail_key + */ + #[Test] + public function public_response_emits_null_when_no_image(): void + { + Page::factory()->create([ + 'slug' => 'thumb-emit-empty', + 'published' => true, + 'published_at' => now(), + 'content' => ['ko' => '

이미지 없는 본문

'], + ]); + + $response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-empty'); + + $response->assertStatus(200); + $this->assertArrayHasKey('content_thumbnail_url', $response->json('data')); + $this->assertNull($response->json('data.content_thumbnail_url')); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts new file mode 100644 index 00000000..c61eceee --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts @@ -0,0 +1,109 @@ +/** + * 페이지 og:image 본문 첫 이미지 공급 — 봇 경로 실측 (공개 이슈 #22, 내부 #610). + * + * 페이지는 og:image 를 공급할 경로가 전무했다. 본문에 이미지를 넣어 저장하면 + * 봇 경로(`?_escaped_fragment_=`)의 서버 렌더 HTML 에 og:image 메타가 절대 URL 로 + * 실리는지, 이미지 없는 페이지는 og:image 가 출력되지 않는지(사이트 기본값 미설정 시) + * 확인한다. og:description 死키 정렬(#11)도 함께 실측한다. + * + * 단위/통합: PageContentThumbnailTest(saving 추출), PageContentThumbnailResourceTest + * (Resource 방출)가 담당하고, 이 spec 은 봇 경로 meta 렌더를 담당한다. + * + * 전용 페이지(slug: e2e-og-image-*)를 spec 안에서 생성/삭제하므로 시드 의존이 없다. + * + * @scenario page-og-image + * @axes image_source=content_internal_only image_source=none + * @effects bot_page_renders_og_image_meta, + * content_image_fills_page_og + */ +import { test as base, expect } from '@playwright/test'; +import { issueToken } from '../../../../../../../tests/Playwright/fixtures/auth'; + +const API = '/api/modules/sirsoft-page'; + +type PageAuthFixtures = { pageManageToken: string }; + +const test = base.extend({ + pageManageToken: async ({}, use) => { + await use(issueToken( + 'sirsoft-page.pages.create', + 'sirsoft-page.pages.read', + 'sirsoft-page.pages.update', + 'sirsoft-page.pages.delete', + )); + }, +}); + +/** API 컨텍스트로 호출한다 (spec 전용 — 페이지 방문 불필요한 셋업/정리용). */ +async function api( + request: import('@playwright/test').APIRequestContext, + bearer: string, + method: 'get' | 'post' | 'delete', + path: string, + data?: unknown, +): Promise<{ status: number; body: any }> { + const response = await request[method](path, { + headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }, + data: data as any, + }); + let body: any = null; + try { + body = await response.json(); + } catch { + /* 비 JSON 응답 허용 */ + } + return { status: response.status(), body }; +} + +test.describe('페이지 og:image 본문 이미지 공급 (공개 #22)', () => { + test('본문 이미지 페이지의 봇 경로 HTML 에 og:image 절대 URL 이 실린다', async ({ request, pageManageToken }) => { + const slug = 'e2e-og-image-filled'; + await api(request, pageManageToken, 'delete', `${API}/admin/pages/by-slug/${slug}`).catch(() => null); + + const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, { + slug, + title: { ko: '[검수] og 이미지 페이지' }, + content: { ko: '

본문

' }, + content_mode: 'html', + published: true, + seo_meta: { description: 'og 설명 검증용' }, + }); + expect([200, 201], `page create failed: ${JSON.stringify(created.body).slice(0, 300)}`).toContain(created.status); + const pageId = created.body?.data?.id; + + // 공개 API 가 캐시를 방출하는지 (레이아웃 og.image 의 데이터 근원) + const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`); + expect(pub.body?.data?.content_thumbnail_url).toBe('/favicon.ico'); + + // 봇 경로 서버 렌더 — og:image 절대 URL + og:description 정렬(#11) + const bot = await request.get(`/page/${slug}?_escaped_fragment_=`); + const html = await bot.text(); + expect(html).toMatch(//); + expect(html).toContain(''); + + // 정리 + const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`); + expect([200, 204]).toContain(del.status); + }); + + test('이미지 없는 페이지는 og:image 가 본문 폴백으로 채워지지 않는다', async ({ request, pageManageToken }) => { + const slug = 'e2e-og-image-empty'; + + const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, { + slug, + title: { ko: '[검수] og 이미지 없는 페이지' }, + content: { ko: '

이미지가 없는 본문입니다.

' }, + content_mode: 'html', + published: true, + }); + expect([200, 201]).toContain(created.status); + const pageId = created.body?.data?.id; + + const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`); + expect(pub.body?.data?.content_thumbnail_url).toBeNull(); + + // 정리 + const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`); + expect([200, 204]).toContain(del.status); + }); +}); diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php new file mode 100644 index 00000000..56e571df --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php @@ -0,0 +1,173 @@ + 'ko']); + } + + protected function tearDown(): void + { + HookManager::clearFilter(self::FILTER_HOOK); + parent::tearDown(); + } + + /** + * @param array $attributes 덮어쓸 속성 + * @return Page 생성된 페이지 + */ + private function createPage(array $attributes = []): Page + { + return Page::factory()->create($attributes); + } + + /** + * @scenario image_source=content_internal_only, locale_content=default_locale + * + * @effects content_image_fills_page_og + */ + #[Test] + public function saving_extracts_first_internal_image(): void + { + $page = $this->createPage([ + 'content' => [ + 'ko' => '

본문

', + 'en' => '', + ], + ]); + + $this->assertSame('/storage/pages/ko-first.jpg', $page->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=content_internal_only, locale_content=other_locale_only + * + * @effects other_locale_image_used_when_default_has_none + */ + #[Test] + public function falls_back_to_other_locale(): void + { + $page = $this->createPage([ + 'content' => [ + 'ko' => '

이미지 없음

', + 'en' => '', + ], + ]); + + $this->assertSame('/storage/pages/en-only.jpg', $page->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=content_external_only, locale_content=default_locale + * + * @effects external_only_content_yields_null + */ + #[Test] + public function external_only_content_yields_null(): void + { + $page = $this->createPage([ + 'content' => ['ko' => ''], + ]); + + $this->assertNull($page->fresh()->content_thumbnail_url); + } + + /** + * @scenario image_source=none, locale_content=default_locale + * + * @effects external_only_content_yields_null + */ + #[Test] + public function text_mode_and_no_image_yield_null(): void + { + $noImage = $this->createPage(['content' => ['ko' => '

이미지 없음

']]); + $textMode = $this->createPage([ + 'content_mode' => 'text', + 'content' => ['ko' => '텍스트 마크업'], + ]); + + $this->assertNull($noImage->fresh()->content_thumbnail_url); + $this->assertNull($textMode->fresh()->content_thumbnail_url, 'text 모드는 캐시하지 않아야 합니다.'); + } + + /** + * @effects recompute_on_content_change + */ + #[Test] + public function content_change_recomputes_and_unrelated_update_does_not(): void + { + $page = $this->createPage([ + 'content' => ['ko' => ''], + ]); + + $page->update(['content' => ['ko' => '']]); + $this->assertSame('/storage/pages/new.jpg', $page->fresh()->content_thumbnail_url); + + DB::table('pages')->where('id', $page->id) + ->update(['content_thumbnail_url' => '/storage/pages/manual.jpg']); + + $page->fresh()->update(['published' => true, 'published_at' => now()]); + + $this->assertSame( + '/storage/pages/manual.jpg', + $page->fresh()->content_thumbnail_url, + 'content 가 dirty 가 아니면 재계산하지 않아야 합니다.' + ); + } + + /** + * @effects filter_hook_can_override_or_block + */ + #[Test] + public function filter_hook_can_override_or_block(): void + { + HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null); + + $blocked = $this->createPage([ + 'content' => ['ko' => ''], + ]); + + $this->assertNull($blocked->fresh()->content_thumbnail_url); + } + + /** + * 버전 롤백 경로(Repository 직접 update)에서도 캐시가 재계산되어야 합니다. + * + * @effects version_restore_recomputes_cache + */ + #[Test] + public function model_level_update_recomputes_cache(): void + { + $page = $this->createPage([ + 'content' => ['ko' => ''], + ]); + + // restoreVersion 은 Repository 를 직접 호출한다 — 모델 update 경로와 동일한 + // saving 이벤트를 타므로 모델 레벨 update 로 등가 검증한다 + Page::findOrFail($page->id)->update([ + 'content' => ['ko' => ''], + ]); + + $this->assertSame('/storage/pages/v2-restored.jpg', $page->fresh()->content_thumbnail_url); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml new file mode 100644 index 00000000..96f48411 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml @@ -0,0 +1,42 @@ +feature: 페이지 og:image 본문 첫 이미지 공급 + +description: | + 공개 이슈 #22 동종 — 페이지는 og:image 를 공급할 경로가 전무했다(본문·첨부·수동 + 어느 경로로도 불가). 저장 시 본문(다국어 JSON) 첫 내부 이미지 URL 을 + pages.content_thumbnail_url 에 캐시하고, PublicPageResource 가 방출해 페이지 표시 + 레이아웃(page/show.json)의 og.image / structured_data.image 가 소비한다. + 별건 #11(og:description 死키 excerpt)도 seo_meta.description 으로 정렬했다. + +axes: + image_source: [content_internal_only, content_external_only, none] + locale_content: [default_locale, other_locale_only] + +exclusions: + - { image_source: content_external_only, locale_content: other_locale_only, reason: "외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null) — default_locale 대표 조합으로 검증" } + - { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" } + +sub_flows: + - id: backfill + description: 1.1.0 업그레이드 스텝이 기존 페이지를 chunkById 백필 (다국어 JSON 파싱 동결 사본) + effects: + - backfill_fills_legacy_pages + - backfill_idempotent_second_run + - id: recompute + description: content/content_mode 변경 시에만 saving 이벤트가 재계산 (버전 롤백 경로 포함) + effects: + - recompute_on_content_change + - filter_hook_can_override_or_block + - version_restore_recomputes_cache + +effects: + - content_image_fills_page_og + - other_locale_image_used_when_default_has_none + - external_only_content_yields_null + - public_resource_emits_thumbnail_key + - bot_page_renders_og_image_meta + +test_files: + - modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php + - modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php + - modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php + - modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts diff --git a/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php new file mode 100644 index 00000000..6b31b712 --- /dev/null +++ b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php @@ -0,0 +1,17 @@ +logger->warning('[page:1.1.0] pages.content_thumbnail_url 미존재 — 스킵'); + + return; + } + + $filled = 0; + $noCandidate = 0; + + // text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상 + DB::table(self::PAGES_TABLE) + ->whereNull('content_thumbnail_url') + ->where('content_mode', 'html') + ->whereNotNull('content') + ->where('content', '!=', '') + ->orderBy('id') + ->select('id', 'content') + ->chunkById(200, function ($pages) use (&$filled, &$noCandidate) { + foreach ($pages as $page) { + $url = $this->firstInternalFromContent((string) $page->content); + + if ($url === null) { + $noCandidate++; + + continue; + } + + DB::table(self::PAGES_TABLE) + ->where('id', $page->id) + ->update(['content_thumbnail_url' => $url]); + + $filled++; + } + }); + + $context->logger->info("[page:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}"); + } + + /** + * 다국어 JSON(또는 레거시 평문) 본문에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본). + * + * @param string $rawContent DB 원시 content 값 + * @return string|null 첫 내부 이미지 URL (없으면 null) + */ + private function firstInternalFromContent(string $rawContent): ?string + { + if (trim($rawContent) === '') { + return null; + } + + $decoded = json_decode($rawContent, true); + + $htmls = is_array($decoded) + ? array_values(array_filter( + [$decoded[config('app.locale')] ?? null, ...array_values($decoded)], + static fn ($html) => is_string($html) && $html !== '' + )) + : [$rawContent]; + + foreach ($htmls as $html) { + $url = $this->firstInternalImageUrl($html); + + if ($url !== null) { + return $url; + } + } + + return null; + } + + /** + * 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본). + * + * @param string $html 본문 HTML + * @return string|null 첫 내부 이미지 URL + */ + private function firstInternalImageUrl(string $html): ?string + { + foreach ($this->imageSources($html) as $src) { + $resolved = $this->resolveInternal($src); + + if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) { + continue; + } + + return $resolved; + } + + return null; + } + + /** + * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본). + * + * @param string $html 본문 HTML + * @return array src 목록 + */ + private function imageSources(string $html): array + { + if (trim($html) === '' || ! class_exists(\DOMDocument::class)) { + return []; + } + + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + + try { + $loaded = $document->loadHTML( + '
' + .$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + + if (! $loaded) { + return []; + } + + $sources = []; + + foreach ($document->getElementsByTagName('img') as $img) { + if (! $img instanceof \DOMElement) { + continue; + } + + $src = trim($img->getAttribute('src')); + + if ($src !== '') { + $sources[] = $src; + } + } + + return $sources; + } catch (\Throwable) { + return []; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함). + * + * @param string $src img src 속성값 + * @return string|null 내부 URL (외부/비허용 스킴이면 null) + */ + private function resolveInternal(string $src): ?string + { + $trimmed = trim($src); + + if ($trimmed === '') { + return null; + } + + $normalized = $this->normalizeForOriginCheck($trimmed); + + if ($normalized === '' || str_starts_with($normalized, '//')) { + return null; + } + + if (str_starts_with($normalized, '/')) { + return $normalized; + } + + $scheme = parse_url($normalized, PHP_URL_SCHEME); + + if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) { + $host = parse_url($normalized, PHP_URL_HOST); + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) { + $path = parse_url($normalized, PHP_URL_PATH); + $query = parse_url($normalized, PHP_URL_QUERY); + + return (is_string($path) && $path !== '' ? $path : '/') + .(is_string($query) && $query !== '' ? '?'.$query : ''); + } + } + + return null; + } + + /** + * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본). + * + * @param string $url 원본 URL + * @return string 정규화된 URL + */ + private function normalizeForOriginCheck(string $url): string + { + $stripped = str_replace(["\t", "\n", "\r"], '', $url); + $slashed = str_replace('\\', '/', $stripped); + + return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed; + } +} diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 09626a51..fd74cf5d 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.7] - 2026-08-22 + +### Added + +- 환경설정 > SEO 탭에서 사이트 기본 공유 이미지(og:image)를 업로드할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 링크 미리보기 카드에 사용됩니다. + ## [1.0.6] - 2026-08-22 ### Added 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 3daac052..a0961cea 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1419,6 +1419,8 @@ "og_image_default_width": "OG Image Default Width (px)", "og_image_default_height": "OG Image Default Height (px)", "og_image_default_size_hint": "Default values when thumbnail size is unknown. Facebook recommends 1200×630, minimum 200×200.", + "og_image_default": "Default Share Image", + "og_image_default_desc": "Site-wide fallback image used when a page has no share image of its own. Shown in link preview cards.", "twitter_default_card": "Twitter Card Default Type", "twitter_default_card_hint": "Use summary_large_image for large image cards, summary for compact ones.", "twitter_default_site": "Twitter Site Handle", 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 613ffb2d..636c0507 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1423,6 +1423,8 @@ "og_image_default_width": "OG 이미지 기본 가로 (px)", "og_image_default_height": "OG 이미지 기본 세로 (px)", "og_image_default_size_hint": "썸네일 실제 크기를 모르는 경우의 기본값입니다. 페이스북은 권장 1200×630, 최소 200×200.", + "og_image_default": "기본 공유 이미지", + "og_image_default_desc": "화면별 공유 이미지가 없을 때 사용할 사이트 기본 이미지입니다. 링크 공유 시 미리보기 카드에 표시됩니다.", "twitter_default_card": "Twitter 카드 기본 타입", "twitter_default_card_hint": "이미지가 큰 카드는 summary_large_image, 작은 카드는 summary 권장.", "twitter_default_site": "Twitter 사이트 핸들", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 8484d60f..9e86b7a6 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -395,6 +395,107 @@ }, "text": "$t:admin.settings.seo.og_image_default_size_hint" }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:admin.settings.seo.og_image_default" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-tertiary mb-2" + }, + "text": "$t:admin.settings.seo.og_image_default_desc" + }, + { + "id": "og_image_default_uploader", + "type": "composite", + "name": "FileUploader", + "props": { + "collection": "og_image_default", + "disabled": "{{_computed.isReadOnly}}", + "maxFiles": 1, + "maxSize": "{{_global.settings?.upload?.max_file_size ?? _global.uploadSettings?.max_file_size ?? 10}}", + "accept": "{{_global.settings?.upload?.allowed_extensions_formatted ?? _global.uploadSettings?.allowed_extensions_formatted ?? '.jpg,.jpeg,.png,.gif,.svg,.webp'}}", + "imageCompression": { + "maxSizeMB": 1, + "maxWidthOrHeight": "{{_global.settings?.upload?.image_max_width ?? _global.uploadSettings?.image_max_width ?? 1200}}" + }, + "initialFiles": "{{settings?.data?.seo?.og_image_default ?? []}}", + "autoUpload": false, + "uploadTriggerEvent": "upload:og_image_default", + "confirmBeforeRemove": true + }, + "actions": [ + { + "event": "onFilesChange", + "type": "change", + "if": "{{$args[0] && $args[0].length > 0}}", + "handler": "setState", + "params": { + "target": "local", + "hasChanges": true + } + }, + { + "event": "onUploadComplete", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "hasChanges": true, + "form.seo.og_image_default": "{{[...(_local.form?.seo?.og_image_default ?? []), ...$args[0]]}}" + } + }, + { + "event": "onUploadComplete", + "type": "custom", + "handler": "toast", + "params": { + "type": "success", + "message": "$t:attachment.upload_success" + } + }, + { + "event": "onUploadError", + "type": "custom", + "handler": "toast", + "params": { + "type": "error", + "message": "{{$args[0]}}" + } + }, + { + "event": "onRemove", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "hasChanges": true, + "form.seo.og_image_default": "{{(_local.form?.seo?.og_image_default ?? []).filter(item => (item.hash || item.id) !== $args[0])}}" + } + }, + { + "event": "onRemove", + "type": "custom", + "handler": "toast", + "params": { + "type": "info", + "message": "$t:attachment.deleted" + } + } + ] + } + ] + }, { "type": "basic", "name": "Div", @@ -1197,7 +1298,7 @@ "props": { "type": "button", "disabled": "{{_computed.isReadOnly || ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", - "className": "w-full md:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap" + "className": "w-full md:w-auto px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 text-white dark:text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap" }, "text": "{{['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '') ? $t('admin.settings.seo.sitemap_regenerating') : $t('admin.settings.seo.sitemap_manual_update')}}", "actions": [ diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json index c783e329..25df1b63 100644 --- a/templates/_bundled/sirsoft-admin_basic/package-lock.json +++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-admin_basic", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json index 90f5d65b..5cd8ef42 100644 --- a/templates/_bundled/sirsoft-admin_basic/package.json +++ b/templates/_bundled/sirsoft-admin_basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.6", + "version": "1.0.7", "description": "Gnuboard7 Basic Admin Template Components", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json index 9c1f06ec..8cd97026 100644 --- a/templates/_bundled/sirsoft-admin_basic/template.json +++ b/templates/_bundled/sirsoft-admin_basic/template.json @@ -5,7 +5,7 @@ "ko": "Admin Basic", "en": "Admin Basic" }, - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "description": { "ko": "그누보드7 기본 관리자 템플릿", @@ -22,7 +22,7 @@ "url": "https://sirsoft.com" }, "release_date": "2026-05-15", - "g7_version": ">=7.0.7", + "g7_version": ">=7.0.9", "dependencies": { "modules": {}, "plugins": {} diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 986c462b..47f13363 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.2] - 2026-08-22 + +### Changed + +- sirsoft-page 최소 버전을 1.1.0 으로 상향했습니다 (페이지 공유 이미지 연동). + +### Fixed + +- 페이지 공유 미리보기의 이미지(og:image)와 설명(og:description)이 채워지도록 페이지 표시 화면의 공유 정보 연결을 정비했습니다. (#22 @abc101 님께서 제보해주셨습니다.) + ## [1.1.1] - 2026-08-19 ### Added diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx new file mode 100644 index 00000000..31b3e79c --- /dev/null +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx @@ -0,0 +1,144 @@ +/** + * @file search-product-item-list-thumbnail.test.tsx + * @description 통합검색 상품 리스트형 아이템 썸네일 키 정정 회귀 (공개 #22 부수 — 별건 P3-A) + * + * 회귀 배경: `_item_list.json` 이 生産측(SearchProductsListener)이 방출하지 않는 + * `product.thumbnail` 키를 바인딩해 리스트형 썸네일 src 가 항상 비었다 (카드형은 정상). + * 정정: `product.thumbnail_url` 바인딩 + if 가드 + 부재 시 placeholder. + * + * 검증 방식: 실제 partial JSON 을 iteration 래퍼에 넣어 DynamicRenderer 로 렌더링, + * thumbnail_url 존재/부재 두 케이스의 DOM 을 단언한다. + * + * @vitest-environment jsdom + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { createLayoutTest, screen } from '@/core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry'; + +import itemListPartial from '../../layouts/partials/search/products/_item_list.json'; + +// ========== 테스트용 컴포넌트 ========== + +const TestDiv: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => ( +
{children}
+); + +const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ className, children, text }) => ( + {children || text} +); + +const TestButton: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => ( + +); + +const TestImg: React.FC<{ src?: string; alt?: string; className?: string }> = ({ src, alt, className }) => ( + {alt} +); + +const TestIcon: React.FC<{ name?: string; className?: string }> = ({ name, className }) => ( + +); + +const TestHtmlContent: React.FC<{ content?: string; className?: string }> = ({ content, className }) => ( +
{content}
+); + +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +function setupTestRegistry(): void { + const registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } }, + Img: { component: TestImg, metadata: { name: 'Img', type: 'basic' } }, + Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } }, + HtmlContent: { component: TestHtmlContent, metadata: { name: 'HtmlContent', type: 'composite' } }, + // iteration 렌더는 Fragment 래퍼를 요구한다 — 미등록 시 반복 전체가 조용히 빈 출력 + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; +} + +/** + * 실제 partial 을 iteration 래퍼에 넣은 테스트 레이아웃을 만든다. + * + * @param products 검색 결과 상품 배열 (SearchProductsListener 방출 형태) + */ +const testLayout = { + version: '1.0.0', + layout_name: 'test/search-item-list', + components: [ + { + type: 'basic', + name: 'Div', + iteration: { + source: 'search_products.data', + item_var: 'product', + index_var: 'product_index', + }, + children: [itemListPartial], + }, + ], +}; + +/** + * 검색 결과 상품 배열을 initialData 컨텍스트로 넘겨 테스트 유틸을 만든다. + * + * @param products 검색 결과 상품 배열 (SearchProductsListener 방출 형태) + */ +function createUtils(products: Array>) { + return createLayoutTest(testLayout as any, { + componentRegistry: ComponentRegistry.getInstance(), + initialData: { search_products: { data: products } }, + } as any); +} + +describe('통합검색 상품 리스트형 썸네일 (partials/search/products/_item_list.json)', () => { + let cleanup: (() => void) | null = null; + + afterEach(() => { + cleanup?.(); + cleanup = null; + }); + + it('thumbnail_url 이 있으면 그 값으로 Img 가 렌더된다 (死키 thumbnail 미의존)', async () => { + setupTestRegistry(); + const utils = createUtils([ + { + id: 1, + name: '검색 상품', + category_name: '카테고리', + thumbnail_url: '/api/plugins/sirsoft-ckeditor5/images/abc123', + selling_price_formatted: '10,000원', + }, + ]); + cleanup = () => utils.cleanup(); + + await utils.render(); + + const img = screen.getByTestId('product-thumb'); + expect(img.getAttribute('src')).toBe('/api/plugins/sirsoft-ckeditor5/images/abc123'); + expect(screen.queryByTestId('icon-image')).toBeNull(); + }); + + it('thumbnail_url 이 없으면 Img 대신 placeholder 아이콘이 렌더된다', async () => { + setupTestRegistry(); + const utils = createUtils([ + { + id: 2, + name: '이미지 없는 상품', + category_name: '카테고리', + thumbnail_url: null, + selling_price_formatted: '5,000원', + }, + ]); + cleanup = () => utils.cleanup(); + + await utils.render(); + + expect(screen.queryByTestId('product-thumb')).toBeNull(); + expect(screen.getByTestId('icon-image')).toBeTruthy(); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/layouts/page/show.json b/templates/_bundled/sirsoft-basic/layouts/page/show.json index 0aaa25ce..7d76ab53 100644 --- a/templates/_bundled/sirsoft-basic/layouts/page/show.json +++ b/templates/_bundled/sirsoft-basic/layouts/page/show.json @@ -15,12 +15,14 @@ "og": { "type": "website", "title": "{{page.data.title ?? ''}}", - "description": "{{page.data.excerpt ?? ''}}" + "description": "{{page.data.seo_meta?.description ?? ''}}", + "image": "{{page.data.content_thumbnail_url ?? ''}}" }, "structured_data": { "@type": "WebPage", "name": "{{page.data.title ?? ''}}", - "description": "{{page.data.excerpt ?? ''}}", + "description": "{{page.data.seo_meta?.description ?? ''}}", + "image": "{{page.data.content_thumbnail_url ?? ''}}", "datePublished": "{{page.data.created_at ?? ''}}", "dateModified": "{{page.data.updated_at ?? ''}}" } @@ -302,7 +304,7 @@ "if": "{{!att_item.is_image}}", "props": { "name": "file", - "className": "text-gray-400 text-sm flex-shrink-0" + "className": "text-gray-400 dark:text-gray-500 text-sm flex-shrink-0" } }, { diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json index 8c6eb36c..fb9c0710 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json @@ -12,9 +12,25 @@ "props": { "className": "w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700" }, "children": [ { + "comment": "생산측(SearchProductsListener)은 thumbnail_url 키를 방출한다 — thumbnail 은 死키였다 (공개 #22 부수 정정)", "type": "basic", "name": "Img", - "props": { "src": "{{product.thumbnail}}", "alt": "{{product.name}}", "className": "w-full h-full object-cover" } + "if": "{{product.thumbnail_url}}", + "props": { "src": "{{product.thumbnail_url}}", "alt": "{{product.name}}", "className": "w-full h-full object-cover" } + }, + { + "comment": "이미지가 없는 경우 플레이스홀더 (board 카드형 패턴 축소 미러)", + "type": "basic", + "name": "Div", + "if": "{{!product.thumbnail_url}}", + "props": { "className": "w-full h-full flex items-center justify-center bg-gray-200 dark:bg-gray-700" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "image", "className": "text-gray-400 dark:text-gray-500" } + } + ] } ] }, diff --git a/templates/_bundled/sirsoft-basic/package-lock.json b/templates/_bundled/sirsoft-basic/package-lock.json index d1d323cf..3458b0ef 100644 --- a/templates/_bundled/sirsoft-basic/package-lock.json +++ b/templates/_bundled/sirsoft-basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-basic", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-basic", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-basic/package.json b/templates/_bundled/sirsoft-basic/package.json index c713487d..22721d03 100644 --- a/templates/_bundled/sirsoft-basic/package.json +++ b/templates/_bundled/sirsoft-basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-basic", - "version": "1.1.1", + "version": "1.1.2", "description": "Gnuboard7 Basic User Template Components - Nexibase Style", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json index 6930220e..34d5c6dc 100644 --- a/templates/_bundled/sirsoft-basic/template.json +++ b/templates/_bundled/sirsoft-basic/template.json @@ -5,7 +5,7 @@ "ko": "Basic", "en": "Basic" }, - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "description": { "ko": "그누보드7 기본 사용자 템플릿", @@ -22,12 +22,12 @@ "url": "https://sirsoft.com" }, "release_date": "2026-01-07", - "g7_version": ">=7.0.6", + "g7_version": ">=7.0.9", "dependencies": { "modules": { "sirsoft-board": ">=1.0.0", "sirsoft-ecommerce": ">=1.1.0", - "sirsoft-page": ">=1.0.0" + "sirsoft-page": ">=1.1.0" }, "plugins": { "sirsoft-daum_postcode": ">=1.0.0" diff --git a/tests/Feature/Settings/OgDefaultImageSettingTest.php b/tests/Feature/Settings/OgDefaultImageSettingTest.php new file mode 100644 index 00000000..c572d347 --- /dev/null +++ b/tests/Feature/Settings/OgDefaultImageSettingTest.php @@ -0,0 +1,126 @@ + 'core', + 'source_identifier' => 'og_image_default', + 'hash' => bin2hex(random_bytes(6)), + 'original_filename' => 'og-default.png', + 'stored_filename' => 'og-default-'.uniqid().'.png', + 'disk' => 'public', + 'path' => 'settings/og-default.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'collection' => 'og_image_default', + 'order' => 0, + ]); + } + + /** + * 기존 설치본에도 새 키가 기본값으로 노출되는지 확인합니다 (defaults 병합). + * + * @scenario image_chain=none, setting_state=absent + * + * @effects og_default_image_persists_to_seo_category + */ + public function test_new_key_present_through_defaults_merge(): void + { + $seo = app(ConfigRepositoryInterface::class)->getCategory('seo'); + + $this->assertArrayHasKey('og_image_default', $seo); + $this->assertSame([], $seo['og_image_default']); + } + + /** + * seo 탭 저장 시 첨부 ID 배열이 정제되어 seo 카테고리에 기록되는지 확인합니다. + * + * @scenario image_chain=site_default, setting_state=saved + * + * @effects og_default_image_persists_to_seo_category + */ + public function test_saved_attachment_ids_persist_to_seo_category(): void + { + $attachment = $this->createOgImageAttachment(); + + $saved = app(SettingsService::class)->saveSettings([ + '_tab' => 'seo', + 'seo' => [ + // 화면 제출 형태(첨부 객체 배열)와 ID 배열 모두 수용해야 한다 + 'og_image_default' => [['id' => $attachment->id, 'hash' => $attachment->hash]], + ], + ]); + + $this->assertTrue($saved, 'seo 탭 저장이 실패했습니다.'); + + $seo = app(ConfigRepositoryInterface::class)->getCategory('seo'); + $this->assertSame([$attachment->id], $seo['og_image_default']); + + // 존재하지 않는 첨부 ID 는 정제 단계에서 걸러진다 + app(SettingsService::class)->saveSettings([ + '_tab' => 'seo', + 'seo' => ['og_image_default' => [999999]], + ]); + + $this->assertSame([], app(ConfigRepositoryInterface::class)->getCategory('seo')['og_image_default']); + } + + /** + * 저장된 첨부가 getOgDefaultImageUrl 로 첫 이미지 URL 로 해석되는지 확인합니다. + * + * @scenario image_chain=site_default, setting_state=saved + * + * @effects og_default_image_resolves_first_image_url + */ + public function test_resolves_first_image_url(): void + { + $attachment = $this->createOgImageAttachment(); + + app(SettingsService::class)->saveSettings([ + '_tab' => 'seo', + 'seo' => ['og_image_default' => [$attachment->id]], + ]); + + $url = app(SettingsService::class)->getOgDefaultImageUrl(); + + $this->assertNotNull($url, '저장된 기본 OG 이미지가 URL 로 해석되어야 합니다.'); + $this->assertStringContainsString($attachment->hash, $url); + } + + /** + * 미설정이면 null 을 돌려주는지 확인합니다 (og:image 태그 미출력 경로). + * + * @scenario image_chain=none, setting_state=absent + * + * @effects og_default_image_resolves_first_image_url + */ + public function test_resolves_null_when_not_configured(): void + { + $this->assertNull(app(SettingsService::class)->getOgDefaultImageUrl()); + } +} diff --git a/tests/Unit/Seo/SeoMetaResolverTest.php b/tests/Unit/Seo/SeoMetaResolverTest.php index 9957dd86..2626b439 100644 --- a/tests/Unit/Seo/SeoMetaResolverTest.php +++ b/tests/Unit/Seo/SeoMetaResolverTest.php @@ -4,7 +4,9 @@ namespace Tests\Unit\Seo; use App\Seo\ExpressionEvaluator; use App\Seo\SeoMetaResolver; +use App\Services\SettingsService; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\URL; use Tests\TestCase; /** @@ -1484,6 +1486,88 @@ class SeoMetaResolverTest extends TestCase $this->assertStringContainsString('@context', $json); } + // ── og:image 사이트 기본값 폴백 (공개 이슈 #22) ────────────── + + /** + * getOgDefaultImageUrl 이 지정 값을 돌려주도록 SettingsService 를 스텁합니다. + * + * @param string|null $url 기본 OG 이미지 URL + */ + private function stubOgDefaultImage(?string $url): void + { + $mock = \Mockery::mock(SettingsService::class); + $mock->shouldReceive('getOgDefaultImageUrl')->andReturn($url); + $this->app->instance(SettingsService::class, $mock); + } + + /** + * 레이아웃 og.image 선언이 있으면 사이트 기본값을 쓰지 않아야 합니다 (선언 우선). + * + * @scenario image_chain=layout_declared, setting_state=saved + * + * @effects layout_declaration_wins_over_site_default + */ + public function test_og_image_declaration_wins_over_site_default(): void + { + $this->stubOgDefaultImage('/storage/settings/site-default.png'); + + $result = $this->resolver->resolveOgData( + ['og' => ['image' => '/storage/posts/declared.jpg']], + [], + 'Fallback Title', + 'Fallback Description' + ); + + $this->assertStringContainsString('/storage/posts/declared.jpg', $result['image']); + $this->assertStringNotContainsString('site-default', $result['image']); + } + + /** + * 선언이 빈 값이면 사이트 기본 OG 이미지로 폴백하고 secure_url 도 파생되어야 합니다. + * + * @scenario image_chain=site_default, setting_state=saved + * + * @effects empty_declaration_falls_back_to_site_default + */ + public function test_og_image_falls_back_to_site_default_and_derives_secure_url(): void + { + // absoluteUrl 은 부팅 시점 URL 루트를 쓰므로 강제 지정으로 고정한다 + URL::forceRootUrl('https://shop.example.com'); + URL::forceScheme('https'); + $this->stubOgDefaultImage('/storage/settings/site-default.png'); + + try { + $result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D'); + } finally { + URL::forceRootUrl(null); + URL::forceScheme(null); + } + + $this->assertSame('https://shop.example.com/storage/settings/site-default.png', $result['image']); + $this->assertSame( + $result['image'], + $result['image_secure_url'], + 'secure_url 은 폴백 적용 후의 image 를 기준으로 파생되어야 합니다.' + ); + } + + /** + * 선언도 기본값도 없으면 image 가 빈 값이어야 합니다 (태그 미출력). + * + * @scenario image_chain=none, setting_state=absent + * + * @effects no_image_emits_nothing + */ + public function test_og_image_empty_when_no_declaration_and_no_default(): void + { + $this->stubOgDefaultImage(null); + + $result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D'); + + $this->assertSame('', $result['image']); + $this->assertSame('', $result['image_secure_url']); + } + /** * 회귀: 코어 설정 site_name 이 다국어 JSON 배열로 저장된 경우에도 * resolveOgData 가 'Array to string conversion' throw 없이 정상 동작. diff --git a/tests/Unit/Support/HtmlImageExtractorTest.php b/tests/Unit/Support/HtmlImageExtractorTest.php new file mode 100644 index 00000000..b1b1c6d4 --- /dev/null +++ b/tests/Unit/Support/HtmlImageExtractorTest.php @@ -0,0 +1,202 @@ + 'https://shop.example.com']); + } + + // ── candidates: src 수집 ────────────────────────── + + public function test_candidates_collects_srcs_in_document_order(): void + { + $html = '

a

'; + + $this->assertSame( + ['/one.jpg', '/two.png', 'https://ext.com/three.gif'], + HtmlImageExtractor::candidates($html) + ); + } + + public function test_candidates_returns_empty_for_no_images(): void + { + $this->assertSame([], HtmlImageExtractor::candidates('

텍스트만 있는 본문

')); + $this->assertSame([], HtmlImageExtractor::candidates('')); + $this->assertSame([], HtmlImageExtractor::candidates(' ')); + } + + public function test_candidates_skips_empty_src(): void + { + $this->assertSame( + ['/real.jpg'], + HtmlImageExtractor::candidates('') + ); + } + + public function test_candidates_survives_broken_html(): void + { + // 미닫힘 태그 + 중첩 오류 — DOM 파서 관용 파싱으로 예외 없이 처리돼야 한다 + $this->assertIsArray(HtmlImageExtractor::candidates('

텍스트

')); + + // 미닫힘이어도 정상 형태의 img 는 수집된다 + $this->assertSame( + ['/ok.jpg'], + HtmlImageExtractor::candidates('

미닫힘 문단

') + ); + } + + public function test_candidates_safe_on_plain_text(): void + { + $this->assertSame([], HtmlImageExtractor::candidates('그냥 평문입니다. assertSame( + ['/single.jpg', '/double.jpg'], + HtmlImageExtractor::candidates("") + ); + } + + // ── firstInternal: 내부 판정 ────────────────────────── + + /** + * @effects content_internal_image_fills_list_thumbnail + */ + public function test_relative_path_passes(): void + { + $this->assertSame( + '/storage/uploads/a.jpg', + HtmlImageExtractor::firstInternal('') + ); + } + + public function test_absolute_same_host_converted_to_relative(): void + { + $this->assertSame( + '/storage/uploads/a.jpg?v=1', + HtmlImageExtractor::firstInternal('') + ); + } + + public function test_absolute_same_host_case_insensitive(): void + { + $this->assertSame( + '/img/a.png', + HtmlImageExtractor::firstInternal('') + ); + } + + /** + * @effects external_only_content_yields_null + */ + public function test_external_host_excluded(): void + { + $this->assertNull( + HtmlImageExtractor::firstInternal('') + ); + } + + public function test_protocol_relative_excluded(): void + { + $this->assertNull( + HtmlImageExtractor::firstInternal('') + ); + } + + /** + * 정규화 공격 문자열 — 문자열상 path 지만 브라우저는 외부 origin 으로 읽는 형태. + * 정규화 SSoT 를 거치지 않으면 내부로 오판된다. + */ + public function test_normalization_attack_strings_judged_external(): void + { + $attacks = [ + '/\\/evil.com/x.jpg', + '\\\\evil.com\\x.jpg', + "/\t/evil.com/x.jpg", + '///evil.com/x.jpg', + ]; + + foreach ($attacks as $attack) { + $this->assertNull( + HtmlImageExtractor::firstInternal(''), + "외부 판정 실패: {$attack}" + ); + } + } + + public function test_non_http_schemes_excluded(): void + { + $this->assertNull(HtmlImageExtractor::firstInternal('')); + $this->assertNull(HtmlImageExtractor::firstInternal('')); + $this->assertNull(HtmlImageExtractor::firstInternal('')); + $this->assertNull(HtmlImageExtractor::firstInternal('')); + } + + public function test_first_external_then_internal_picks_internal(): void + { + $html = ''; + + $this->assertSame('/second.jpg', HtmlImageExtractor::firstInternal($html)); + } + + public function test_no_image_returns_null(): void + { + $this->assertNull(HtmlImageExtractor::firstInternal('

이미지 없음

')); + $this->assertNull(HtmlImageExtractor::firstInternal('')); + } + + public function test_extra_allowed_prefix_passes_with_original_form(): void + { + $html = ''; + + $this->assertNull(HtmlImageExtractor::firstInternal($html)); + $this->assertSame( + 'https://cdn.example.net/bucket/a.jpg', + HtmlImageExtractor::firstInternal($html, ['https://cdn.example.net/']) + ); + } + + public function test_extra_prefix_does_not_rescue_unlisted_host(): void + { + $this->assertNull( + HtmlImageExtractor::firstInternal( + '', + ['https://cdn.example.net/'] + ) + ); + } + + public function test_url_over_1000_chars_excluded(): void + { + $longUrl = '/storage/'.str_repeat('a', 1000).'.jpg'; + $html = ''; + + $this->assertSame('/short.jpg', HtmlImageExtractor::firstInternal($html)); + } + + public function test_absolute_same_host_without_path_returns_root(): void + { + $this->assertSame( + '/', + HtmlImageExtractor::firstInternal('') + ); + } +} diff --git a/tests/scenarios/seo-og-default-image.yaml b/tests/scenarios/seo-og-default-image.yaml new file mode 100644 index 00000000..d0e4670f --- /dev/null +++ b/tests/scenarios/seo-og-default-image.yaml @@ -0,0 +1,28 @@ +feature: 사이트 기본 OG 이미지 폴백 + +description: | + 공개 이슈 #22 동종(증폭 레이어) — SeoMetaResolver 의 og:image 는 title/description 과 + 달리 폴백 체인이 없어, 레이아웃 선언이 비면 그대로 비었다. 환경설정 > SEO 에 사이트 + 기본 공유 이미지(seo.og_image_default, 첨부 ID 배열)를 신설하고, resolveOgData 가 + 선언이 빈 값일 때 이 기본값으로 폴백한다. 체인: 레이아웃 선언(도메인 본문 캐시 포함) + → 사이트 기본 이미지 → 빈 값(태그 미출력). secure_url 은 폴백 적용 후 image 기준 파생. + +axes: + image_chain: [layout_declared, site_default, none] + setting_state: [saved, absent] + +exclusions: + - { image_chain: layout_declared, setting_state: absent, reason: "선언이 있으면 기본값 설정 유무는 결과에 관여하지 않는다 — saved 대표 조합이 선언 우선을 검증" } + - { image_chain: site_default, setting_state: absent, reason: "기본값 미설정이면 site_default 체인 자체가 성립하지 않는다 (none 조합이 그 상태)" } + - { image_chain: none, setting_state: saved, reason: "none 은 선언·기본값 모두 부재의 정의 — saved 와 모순 조합" } + +effects: + - layout_declaration_wins_over_site_default + - empty_declaration_falls_back_to_site_default + - no_image_emits_nothing + - og_default_image_persists_to_seo_category + - og_default_image_resolves_first_image_url + +test_files: + - tests/Unit/Seo/SeoMetaResolverTest.php + - tests/Feature/Settings/OgDefaultImageSettingTest.php