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
@@ -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'),
|
||||
|
||||
@@ -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 : '');
|
||||
|
||||
@@ -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<int, int> 저장할 첨부파일 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<int, int> 저장할 첨부 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<int, int> 파기 대상 첨부 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<int, int> $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 를 가려냅니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 이미지 URL 후보를 추출하는 공용 유틸리티.
|
||||
*
|
||||
* 게시글(board)·상품 설명(ecommerce)·페이지(page)처럼 에디터로 작성된 본문에서
|
||||
* "첫 번째 내부 이미지" 를 뽑아 목록 썸네일·og:image 폴백 캐시로 쓰기 위한 계약이다.
|
||||
* 내부/외부 판정은 origin 정규화 SSoT 인
|
||||
* {@see TrustedScriptHosts::normalizeForOriginCheck()} 를 그대로 재사용한다 —
|
||||
* 문자열상 path 로 보이지만 브라우저가 외부 origin 으로 읽는 형태
|
||||
* (`/\/evil.com/x.jpg` 등)를 내부로 오판하지 않기 위함이다.
|
||||
*
|
||||
* 다국어 JSON 콘텐츠(로케일별 HTML 배열)는 호출측이 로케일을 순회하며 이 클래스에는
|
||||
* 단일 HTML 문자열만 전달한다.
|
||||
*/
|
||||
final class HtmlImageExtractor
|
||||
{
|
||||
/**
|
||||
* 반환 URL 최대 길이 (캐시 컬럼 `string(1000)` 상한 정합).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
/**
|
||||
* 파서가 요구하는 문서 골격을 씌울 래퍼 요소 id.
|
||||
*/
|
||||
private const ROOT_ID = 'g7-html-image-extractor-root';
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 `<img>` 의 `src` 속성을 문서 순서로 수집합니다.
|
||||
*
|
||||
* ext-dom 부재·파싱 실패 시에는 안전측(빈 배열)으로 폴백합니다.
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> 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(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div id="'
|
||||
.self::ROOT_ID.'">'.$html.'</div></body></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<int, string> $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<int, string> $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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user