Skip to content

Commit 8efe90a

Browse files
fix(schema): サイト構造化データを json_ld フィルタ経由の安全な出力に変更
Organization / WebSite の JSON-LD をテンプレートの文字列補間で組み立てていたため、 店名等に " や </script> が含まれると構造化データが壊れる/XSS 経路になりうる問題があった (CodeRabbit 指摘)。#6883 で導入済みの json_ld フィルタ(JSON_HEX_* でエスケープ)に揃える。 - SiteStructuredDataService を新設し WebSite / Organization の連想配列を組み立て - TwigInitializeListener で site_json_ld を front グローバルに注入 - default_frame.twig は {{ site_json_ld|json_ld }} で出力 - 旧 Schema/organization.twig・website.twig を削除 - 値が空の任意プロパティは出力しない(ProductStructuredDataService と同方針) - 都道府県未設定(Pref=null)でもフロントが 500 にならない(元 PR の不具合も解消) - SiteStructuredDataServiceTest を追加 出力される JSON-LD は、値のある項目については従来と同一 (空の任意プロパティが省略される点のみ差分)。 Refs #6136 #6147 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f364a6 commit 8efe90a

6 files changed

Lines changed: 278 additions & 68 deletions

File tree

src/Eccube/EventListener/TwigInitializeListener.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Eccube\Repository\PageLayoutRepository;
3232
use Eccube\Repository\PageRepository;
3333
use Eccube\Request\Context;
34+
use Eccube\Service\SiteStructuredDataService;
3435
use Eccube\Service\SystemService;
3536
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
3637
use Symfony\Component\HttpFoundation\ParameterBag;
@@ -49,7 +50,7 @@ class TwigInitializeListener implements EventSubscriberInterface
4950
/**
5051
* TwigInitializeListener constructor.
5152
*/
52-
public function __construct(protected Environment $twig, protected BaseInfoRepository $baseInfoRepository, protected PageRepository $pageRepository, protected PageLayoutRepository $pageLayoutRepository, protected BlockPositionRepository $blockPositionRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly AuthorityRoleRepository $authorityRoleRepository, private EccubeConfig $eccubeConfig, protected Context $requestContext, private readonly MobileDetect $mobileDetector, private readonly UrlGeneratorInterface $router, private readonly LayoutRepository $layoutRepository, protected SystemService $systemService)
53+
public function __construct(protected Environment $twig, protected BaseInfoRepository $baseInfoRepository, protected PageRepository $pageRepository, protected PageLayoutRepository $pageLayoutRepository, protected BlockPositionRepository $blockPositionRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly AuthorityRoleRepository $authorityRoleRepository, private EccubeConfig $eccubeConfig, protected Context $requestContext, private readonly MobileDetect $mobileDetector, private readonly UrlGeneratorInterface $router, private readonly LayoutRepository $layoutRepository, protected SystemService $systemService, private readonly SiteStructuredDataService $siteStructuredDataService)
5354
{
5455
}
5556

@@ -177,6 +178,7 @@ public function setFrontVariables(RequestEvent $event): void
177178
$this->twig->addGlobal('title', $Page->getName());
178179
$this->twig->addGlobal('isMaintenance', $this->systemService->isMaintenanceMode());
179180
$this->twig->addGlobal('isDebugMode', env('APP_DEBUG'));
181+
$this->twig->addGlobal('site_json_ld', $this->siteStructuredDataService->createWebSiteJsonLd($this->baseInfoRepository->get()));
180182
}
181183

182184
public function setAdminGlobals(RequestEvent $event): void

src/Eccube/Resource/template/default/Schema/organization.twig

Lines changed: 0 additions & 44 deletions
This file was deleted.

src/Eccube/Resource/template/default/Schema/website.twig

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/Eccube/Resource/template/default/default_frame.twig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ file that was distributed with this source code.
187187
{{ include('snippet.twig', { snippets: plugin_snippets }) }}
188188
{% endif %}
189189
<script src="{{ asset('assets/js/customize.js', 'user_data') }}"></script>
190-
<script type="application/ld+json">
191-
{{ include('Schema/website.twig') }}
192-
</script>
190+
{% if site_json_ld is defined and site_json_ld %}
191+
<script type="application/ld+json">{{ site_json_ld|json_ld }}</script>
192+
{% endif %}
193193
</body>
194194
</html>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
/*
4+
* This file is part of EC-CUBE
5+
*
6+
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
7+
*
8+
* http://www.ec-cube.co.jp/
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Eccube\Service;
15+
16+
use Eccube\Entity\BaseInfo;
17+
use Symfony\Component\Asset\Packages;
18+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
19+
20+
/**
21+
* サイト共通の構造化データ(JSON-LD / schema.org WebSite・Organization)を組み立てる Service.
22+
*
23+
* 店舗設定(BaseInfo)から WebSite / Organization の連想配列を組み立てて返す。
24+
* 戻り値は EccubeExtension の `json_ld` フィルタ経由で出力し、値に含まれる
25+
* `" < > &` などを機械的にエスケープする(テンプレート文字列補間による JSON 破壊・XSS を防ぐ)。
26+
*
27+
* 値が空の任意プロパティは出力しない(ProductStructuredDataService と同じ方針)。
28+
*/
29+
class SiteStructuredDataService
30+
{
31+
/**
32+
* ロゴに用いるファビコン画像ファイル(user_data 配下).
33+
*/
34+
private const LOGO_FILE = 'assets/img/common/favicon.ico';
35+
36+
public function __construct(
37+
private readonly UrlGeneratorInterface $urlGenerator,
38+
private readonly Packages $packages,
39+
) {
40+
}
41+
42+
/**
43+
* サイト共通の JSON-LD 構造(WebSite。author に Organization を内包)を組み立てて返す.
44+
*
45+
* @return array<string, mixed>
46+
*/
47+
public function createWebSiteJsonLd(BaseInfo $BaseInfo): array
48+
{
49+
$data = [
50+
'@context' => 'https://schema.org',
51+
'@type' => 'WebSite',
52+
'name' => (string) $BaseInfo->getShopName(),
53+
];
54+
$this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng());
55+
$data['url'] = $this->generateAbsoluteUrl('homepage');
56+
$this->addIfNotEmpty($data, 'description', $BaseInfo->getGoodTraded());
57+
$data['potentialAction'] = [
58+
'@type' => 'SearchAction',
59+
'target' => [
60+
'@type' => 'EntryPoint',
61+
'urlTemplate' => $this->generateAbsoluteUrl('product_list').'?name={search_term_string}',
62+
],
63+
'query-input' => 'required name=search_term_string',
64+
];
65+
$data['author'] = $this->createOrganizationJsonLd($BaseInfo);
66+
67+
return $data;
68+
}
69+
70+
/**
71+
* Organization の JSON-LD 構造を組み立てて返す.
72+
*
73+
* @return array<string, mixed>
74+
*/
75+
public function createOrganizationJsonLd(BaseInfo $BaseInfo): array
76+
{
77+
$data = [
78+
'@context' => 'https://schema.org',
79+
'@type' => 'Organization',
80+
'url' => $this->generateAbsoluteUrl('homepage'),
81+
'logo' => [
82+
'@type' => 'ImageObject',
83+
'contentUrl' => $this->packages->getUrl(self::LOGO_FILE, 'user_data'),
84+
],
85+
'name' => (string) $BaseInfo->getShopName(),
86+
];
87+
$this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng());
88+
$this->addIfNotEmpty($data, 'legalName', $BaseInfo->getCompanyName());
89+
$this->addIfNotEmpty($data, 'description', $BaseInfo->getMessage());
90+
$this->addIfNotEmpty($data, 'email', $BaseInfo->getEmail01());
91+
92+
$phoneNumber = $BaseInfo->getPhoneNumber();
93+
if ($phoneNumber !== null && $phoneNumber !== '') {
94+
$data['telephone'] = '+81-'.$phoneNumber;
95+
}
96+
97+
$address = $this->buildAddress($BaseInfo);
98+
if ($address !== null) {
99+
$data['address'] = $address;
100+
}
101+
102+
$contactPoint = $this->buildContactPoint($BaseInfo, $phoneNumber);
103+
if ($contactPoint !== null) {
104+
$data['contactPoint'] = $contactPoint;
105+
}
106+
107+
if ($BaseInfo->getInvoiceRegistrationNumber() !== null) {
108+
$data['iso6523Code'] = '0221:'.$BaseInfo->getInvoiceRegistrationNumber();
109+
}
110+
111+
return $data;
112+
}
113+
114+
/**
115+
* PostalAddress 構造を組み立てる(住所要素が1つも無ければ null).
116+
*
117+
* @return array<string, mixed>|null
118+
*/
119+
private function buildAddress(BaseInfo $BaseInfo): ?array
120+
{
121+
$address = ['@type' => 'PostalAddress'];
122+
$this->addIfNotEmpty($address, 'streetAddress', $BaseInfo->getAddr02());
123+
$this->addIfNotEmpty($address, 'addressLocality', $BaseInfo->getAddr01());
124+
$this->addIfNotEmpty($address, 'addressRegion', $BaseInfo->getPref()?->getName());
125+
$this->addIfNotEmpty($address, 'postalCode', $BaseInfo->getPostalCode());
126+
127+
// @type 以外に住所要素が無ければ出力しない
128+
if (count($address) === 1) {
129+
return null;
130+
}
131+
132+
$address['addressCountry'] = 'JP';
133+
134+
return $address;
135+
}
136+
137+
/**
138+
* ContactPoint 構造を組み立てる(連絡手段が1つも無ければ null).
139+
*
140+
* @return array<string, mixed>|null
141+
*/
142+
private function buildContactPoint(BaseInfo $BaseInfo, ?string $phoneNumber): ?array
143+
{
144+
$contactPoint = ['@type' => 'ContactPoint'];
145+
if ($phoneNumber !== null && $phoneNumber !== '') {
146+
$contactPoint['telephone'] = '+81-'.$phoneNumber;
147+
}
148+
$this->addIfNotEmpty($contactPoint, 'email', $BaseInfo->getEmail02());
149+
150+
// telephone / email が無ければ url だけの ContactPoint は出力しない
151+
if (!isset($contactPoint['telephone']) && !isset($contactPoint['email'])) {
152+
return null;
153+
}
154+
155+
$contactPoint['url'] = $this->generateAbsoluteUrl('contact');
156+
157+
return $contactPoint;
158+
}
159+
160+
/**
161+
* ルート名から絶対URLを生成する.
162+
*/
163+
private function generateAbsoluteUrl(string $route): string
164+
{
165+
return $this->urlGenerator->generate($route, [], UrlGeneratorInterface::ABSOLUTE_URL);
166+
}
167+
168+
/**
169+
* 値が null / 空文字でない場合のみ連想配列へ追加する.
170+
*
171+
* @param array<string, mixed> $data
172+
*/
173+
private function addIfNotEmpty(array &$data, string $key, ?string $value): void
174+
{
175+
if ($value !== null && $value !== '') {
176+
$data[$key] = $value;
177+
}
178+
}
179+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of EC-CUBE
7+
*
8+
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
9+
*
10+
* http://www.ec-cube.co.jp/
11+
*
12+
* For the full copyright and license information, please view the LICENSE
13+
* file that was distributed with this source code.
14+
*/
15+
16+
namespace Eccube\Tests\Service;
17+
18+
use Eccube\Entity\BaseInfo;
19+
use Eccube\Repository\BaseInfoRepository;
20+
use Eccube\Service\SiteStructuredDataService;
21+
22+
final class SiteStructuredDataServiceTest extends AbstractServiceTestCase
23+
{
24+
private ?SiteStructuredDataService $service = null;
25+
26+
private ?BaseInfo $BaseInfo = null;
27+
28+
protected function setUp(): void
29+
{
30+
parent::setUp();
31+
$this->service = static::getContainer()->get(SiteStructuredDataService::class);
32+
$this->BaseInfo = static::getContainer()->get(BaseInfoRepository::class)->get();
33+
}
34+
35+
public function testWebSiteBaseStructure(): void
36+
{
37+
$data = $this->service->createWebSiteJsonLd($this->BaseInfo);
38+
39+
$this->assertSame('https://schema.org', $data['@context']);
40+
$this->assertSame('WebSite', $data['@type']);
41+
$this->assertSame((string) $this->BaseInfo->getShopName(), $data['name']);
42+
$this->assertStringStartsWith('http', $data['url']);
43+
$this->assertSame('SearchAction', $data['potentialAction']['@type']);
44+
$this->assertArrayHasKey('author', $data);
45+
$this->assertSame('Organization', $data['author']['@type']);
46+
}
47+
48+
public function testOrganizationBaseStructure(): void
49+
{
50+
$data = $this->service->createOrganizationJsonLd($this->BaseInfo);
51+
52+
$this->assertSame('https://schema.org', $data['@context']);
53+
$this->assertSame('Organization', $data['@type']);
54+
$this->assertStringStartsWith('http', $data['url']);
55+
$this->assertSame('ImageObject', $data['logo']['@type']);
56+
$this->assertNotEmpty($data['logo']['contentUrl']);
57+
$this->assertSame((string) $this->BaseInfo->getShopName(), $data['name']);
58+
}
59+
60+
public function testEmptyOptionalPropertiesAreOmitted(): void
61+
{
62+
$this->BaseInfo->setShopNameEng(null);
63+
$this->BaseInfo->setGoodTraded(null);
64+
65+
$data = $this->service->createWebSiteJsonLd($this->BaseInfo);
66+
67+
$this->assertArrayNotHasKey('alternateName', $data);
68+
$this->assertArrayNotHasKey('description', $data);
69+
$this->assertArrayNotHasKey('alternateName', $data['author']);
70+
}
71+
72+
public function testPrefNullOmitsAddressRegionWithoutError(): void
73+
{
74+
$this->BaseInfo->setPref(null);
75+
76+
$data = $this->service->createOrganizationJsonLd($this->BaseInfo);
77+
78+
// 都道府県未設定でも例外にならず、addressRegion のみ欠落する
79+
$this->assertSame('Organization', $data['@type']);
80+
if (isset($data['address'])) {
81+
$this->assertArrayNotHasKey('addressRegion', $data['address']);
82+
}
83+
}
84+
85+
public function testInvoiceRegistrationNumberIsIncludedWhenPresent(): void
86+
{
87+
$this->BaseInfo->setInvoiceRegistrationNumber('T1234567890123');
88+
89+
$data = $this->service->createOrganizationJsonLd($this->BaseInfo);
90+
91+
$this->assertSame('0221:T1234567890123', $data['iso6523Code']);
92+
}
93+
}

0 commit comments

Comments
 (0)