feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
공개 이슈 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 모드 오캐시 결함은 실패 테스트 선행 후 수정
This commit is contained in:
1 parent
14eb2e39e4
commit
1ba67ca7cf
98 files changed
+4640
-101
No files matched your search
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Models\Attachment;
|
||||
use App\Services\SettingsService;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 사이트 기본 OG 이미지(seo.og_image_default) 설정의 저장·읽기·해석을 고정합니다 (공개 #22).
|
||||
*
|
||||
* 이 설정은 화면(FileUploader) 한 곳에서 입력되지만 네 지점을 지나야 동작합니다 —
|
||||
* 검증(SaveSettingsRequest), 저장(seo 카테고리 + 첨부 ID 정제), 관리자 재로드(첨부
|
||||
* 객체 변환), 해석(getOgDefaultImageUrl → SeoMetaResolver og:image 폴백). 어느 한
|
||||
* 지점이 빠지면 "저장은 되는데 og:image 는 비는" 상태가 되고 화면에 드러나지 않습니다.
|
||||
*
|
||||
* @effects og_default_image_persists_to_seo_category
|
||||
* @effects og_default_image_resolves_first_image_url
|
||||
*/
|
||||
class OgDefaultImageSettingTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* og_image_default 컬렉션 이미지 첨부를 만듭니다.
|
||||
*
|
||||
* @return Attachment 생성된 첨부
|
||||
*/
|
||||
private function createOgImageAttachment(): Attachment
|
||||
{
|
||||
return Attachment::create([
|
||||
'source_type' => '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());
|
||||
}
|
||||
}
|
||||
@@ -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 없이 정상 동작.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\HtmlImageExtractor;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HtmlImageExtractor 추출/내부 판정 테스트 (공개 이슈 #22)
|
||||
*
|
||||
* 본문 HTML 의 첫 내부 이미지를 목록 썸네일·og:image 폴백 캐시로 쓰는 공용 계약을
|
||||
* 고정합니다. 내부/외부 판정은 TrustedScriptHosts::normalizeForOriginCheck (origin
|
||||
* 정규화 SSoT) 를 경유하므로, 브라우저가 외부 origin 으로 읽는 공격 문자열
|
||||
* (`/\/evil.com/x.jpg` 등)이 내부로 오판되면 비신뢰 호스트 이미지가 사이트 파생
|
||||
* 표면(목록·og:image)에 실립니다.
|
||||
*/
|
||||
class HtmlImageExtractorTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.url' => 'https://shop.example.com']);
|
||||
}
|
||||
|
||||
// ── candidates: src 수집 ──────────────────────────
|
||||
|
||||
public function test_candidates_collects_srcs_in_document_order(): void
|
||||
{
|
||||
$html = '<p>a</p><img src="/one.jpg"><div><img src="/two.png"></div><img src="https://ext.com/three.gif">';
|
||||
|
||||
$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('<p>텍스트만 있는 본문</p>'));
|
||||
$this->assertSame([], HtmlImageExtractor::candidates(''));
|
||||
$this->assertSame([], HtmlImageExtractor::candidates(' '));
|
||||
}
|
||||
|
||||
public function test_candidates_skips_empty_src(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['/real.jpg'],
|
||||
HtmlImageExtractor::candidates('<img src=""><img><img src=" "><img src="/real.jpg">')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_candidates_survives_broken_html(): void
|
||||
{
|
||||
// 미닫힘 태그 + 중첩 오류 — DOM 파서 관용 파싱으로 예외 없이 처리돼야 한다
|
||||
$this->assertIsArray(HtmlImageExtractor::candidates('<div><p><img src="/broken.jpg"<span>텍스트</div>'));
|
||||
|
||||
// 미닫힘이어도 정상 형태의 img 는 수집된다
|
||||
$this->assertSame(
|
||||
['/ok.jpg'],
|
||||
HtmlImageExtractor::candidates('<div><img src="/ok.jpg"><p>미닫힘 문단</div>')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_candidates_safe_on_plain_text(): void
|
||||
{
|
||||
$this->assertSame([], HtmlImageExtractor::candidates('그냥 평문입니다. <img 처럼 보이는 텍스트'));
|
||||
}
|
||||
|
||||
public function test_candidates_handles_single_quoted_attributes(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['/single.jpg', '/double.jpg'],
|
||||
HtmlImageExtractor::candidates("<img src='/single.jpg'><img src=\"/double.jpg\">")
|
||||
);
|
||||
}
|
||||
|
||||
// ── firstInternal: 내부 판정 ──────────────────────────
|
||||
|
||||
/**
|
||||
* @effects content_internal_image_fills_list_thumbnail
|
||||
*/
|
||||
public function test_relative_path_passes(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'/storage/uploads/a.jpg',
|
||||
HtmlImageExtractor::firstInternal('<img src="/storage/uploads/a.jpg">')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_absolute_same_host_converted_to_relative(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'/storage/uploads/a.jpg?v=1',
|
||||
HtmlImageExtractor::firstInternal('<img src="https://shop.example.com/storage/uploads/a.jpg?v=1">')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_absolute_same_host_case_insensitive(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'/img/a.png',
|
||||
HtmlImageExtractor::firstInternal('<img src="https://SHOP.Example.COM/img/a.png">')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects external_only_content_yields_null
|
||||
*/
|
||||
public function test_external_host_excluded(): void
|
||||
{
|
||||
$this->assertNull(
|
||||
HtmlImageExtractor::firstInternal('<img src="https://evil.com/x.jpg">')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_protocol_relative_excluded(): void
|
||||
{
|
||||
$this->assertNull(
|
||||
HtmlImageExtractor::firstInternal('<img src="//evil.com/x.jpg">')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화 공격 문자열 — 문자열상 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('<img src="'.htmlspecialchars($attack, ENT_QUOTES).'">'),
|
||||
"외부 판정 실패: {$attack}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_http_schemes_excluded(): void
|
||||
{
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal('<img src="data:image/png;base64,AAAA">'));
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal('<img src="javascript:alert(1)">'));
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal('<img src="blob:https://shop.example.com/uuid">'));
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal('<img src="ftp://shop.example.com/a.jpg">'));
|
||||
}
|
||||
|
||||
public function test_first_external_then_internal_picks_internal(): void
|
||||
{
|
||||
$html = '<img src="https://evil.com/first.jpg"><img src="/second.jpg">';
|
||||
|
||||
$this->assertSame('/second.jpg', HtmlImageExtractor::firstInternal($html));
|
||||
}
|
||||
|
||||
public function test_no_image_returns_null(): void
|
||||
{
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal('<p>이미지 없음</p>'));
|
||||
$this->assertNull(HtmlImageExtractor::firstInternal(''));
|
||||
}
|
||||
|
||||
public function test_extra_allowed_prefix_passes_with_original_form(): void
|
||||
{
|
||||
$html = '<img src="https://cdn.example.net/bucket/a.jpg">';
|
||||
|
||||
$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(
|
||||
'<img src="https://evil.com/a.jpg">',
|
||||
['https://cdn.example.net/']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_url_over_1000_chars_excluded(): void
|
||||
{
|
||||
$longUrl = '/storage/'.str_repeat('a', 1000).'.jpg';
|
||||
$html = '<img src="'.$longUrl.'"><img src="/short.jpg">';
|
||||
|
||||
$this->assertSame('/short.jpg', HtmlImageExtractor::firstInternal($html));
|
||||
}
|
||||
|
||||
public function test_absolute_same_host_without_path_returns_root(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'/',
|
||||
HtmlImageExtractor::firstInternal('<img src="https://shop.example.com">')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user