feat: add RichTextEditor component for rich text editing in articles

feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
This commit is contained in:
hamed
2026-08-08 11:40:17 +03:30
parent 934405c42d
commit 47323daa27
36 changed files with 3461 additions and 12686 deletions
@@ -0,0 +1,188 @@
<?php
namespace App\Blog\Command;
use App\Blog\Service\BlogBodySanitizer;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* پاک‌سازی بدنهٔ مقاله‌های موجود با همان سیاستی که مسیر ذخیره اعمال می‌کند.
*
* یافتهٔ ۵ آدیت ۲۰۲۶-۰۸-۰۷ دفاع را در **لحظهٔ ذخیره** گذاشت، پس مقاله‌هایی که پیش
* از آن ذخیره شده‌اند هنوز HTML خام دارند و `BlogReviewPage` خامشان را رندر می‌کند.
* این دستور همان بدهی را تسویه می‌کند.
*
* دستور است نه migration: سیاستِ `html_sanitizer.yaml` ممکن است دوباره سفت شود و
* آن‌وقت باید همین گذر دوباره اجرا شود. migration یک‌بارمصرف است.
*
* بدنه‌ای که پس از پاک‌سازی کاملاً خالی می‌شود دست‌نخورده می‌ماند و فقط گزارش
* می‌شود: مقالهٔ منتشرشده را نباید بی‌صدا تهی کرد — تصمیمش با آدم است.
*/
#[AsCommand(
name: 'app:blog:sanitize-bodies',
description: 'Re-runs the blog body sanitizer over rows saved before it existed',
)]
class SanitizeBlogBodiesCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly BlogBodySanitizer $sanitizer,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس');
$this->addOption('show', null, InputOption::VALUE_REQUIRED, 'قبل/بعدِ یک مقاله را چاپ کن (id)');
}
/**
* جنسِ تغییرِ یک بدنه: `stripped` یا `hardened`.
*
* تفکیک لازم است چون پاک‌سازی سه کارِ متفاوت می‌کند و فقط یکی‌شان امنیتی است:
*
* - decode شدن entity (`&nbsp;` → U+00A0) — بی‌اثر.
* - افزودن `rel="noopener noreferrer"` به `<a>` — سخت‌سازی، سیاستِ
* `html_sanitizer.yaml`. جلوی reverse tabnabbing را می‌گیرد.
* - **حذفِ** تگ یا attribute — تنها حالتی که یعنی آن مقاله markupِ غیرمجاز دارد.
*
* بدونِ این تفکیک، عددِ «۴۲۶ مقاله تغییر می‌کند» گمراه‌کننده بود و یک `UPDATE`
* انبوه را به‌جای یک بررسی هدفمند توجیه می‌کرد.
*/
private static function classify(string $before, string $after): string
{
// `<br>` → `<br />` فقط سریال‌سازیِ خروجی است. بدون یکسان‌سازی، هر مقالهٔ
// دارای خط‌شکن به‌غلط «markup غیرمجاز» گزارش می‌شد.
$tags = static function (string $html): array {
preg_match_all('/<[^>]+>/', $html, $m);
return array_map(
static fn (string $t): string => preg_replace('/\s*\/>$/', '>', $t) ?? $t,
$m[0],
);
};
// سیاست، `rel` را روی هر `<a>` **تحمیل** می‌کند. پس هم نبودنش و هم مقدارِ
// ضعیف‌ترِ قبلی (`rel="noopener"`) با مقدار کامل جایگزین می‌شود. هر دو سمت
// نرمال می‌شوند تا این سخت‌سازی به‌غلط «حذف» شمرده نشود.
$withoutRel = static fn (string $tag): string => preg_replace(
'/\s+rel="[^"]*"/',
'',
$tag,
) ?? $tag;
$beforeTags = array_map($withoutRel, $tags($before));
$afterTags = array_map($withoutRel, $tags($after));
if ($beforeTags !== $afterTags) {
return 'stripped';
}
$text = static function (string $html): string {
$decoded = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return preg_replace('/\s+/u', ' ', str_replace("\u{a0}", ' ', $decoded)) ?? '';
};
return $text($before) === $text($after) ? 'hardened' : 'stripped';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
if (($showId = $input->getOption('show')) !== null) {
$before = (string) $this->connection->fetchOne('SELECT body FROM blogs WHERE id = ?', [(int) $showId]);
file_put_contents(sys_get_temp_dir() . '/blog-before.html', $before);
file_put_contents(sys_get_temp_dir() . '/blog-after.html', $this->sanitizer->clean($before));
$io->success(sys_get_temp_dir() . '/blog-{before,after}.html نوشته شد');
return Command::SUCCESS;
}
$rows = $this->connection->fetchAllAssociative('SELECT id, title, body FROM blogs');
$changed = [];
$emptied = [];
$hardened = 0;
$stripped = [];
foreach ($rows as $row) {
$before = (string) $row['body'];
$after = $this->sanitizer->clean($before);
if ($after === $before) {
continue;
}
if (trim(strip_tags($after)) === '') {
$emptied[] = $row;
continue;
}
if (self::classify($before, $after) === 'hardened') {
$hardened++;
} else {
$stripped[] = sprintf('#%d — %s', $row['id'], $row['title']);
}
$changed[] = ['id' => (int) $row['id'], 'title' => (string) $row['title'], 'body' => $after];
}
$io->section(sprintf('%d مقاله بررسی شد', count($rows)));
$io->definitionList(
['بدون تغییر' => count($rows) - count($changed) - count($emptied)],
['سخت‌سازی (افزودن rel / decode شدن entity)' => $hardened],
['حذفِ تگ یا attribute غیرمجاز' => count($stripped)],
['خالی می‌شد و دست‌نخورده ماند' => count($emptied)],
);
if ($stripped !== []) {
$io->warning('این مقاله‌ها markupِ غیرمجاز دارند:');
$io->listing(array_slice($stripped, 0, 30));
}
if ($emptied !== []) {
$io->warning(sprintf(
'%d مقاله پس از پاک‌سازی خالی می‌شد و دست‌نخورده ماند. دستی بررسی کن:',
count($emptied),
));
$io->listing(array_map(
static fn (array $r): string => sprintf('#%d — %s', $r['id'], $r['title']),
$emptied,
));
}
if ($changed === []) {
$io->success('هیچ بدنه‌ای تغییر نکرد؛ همه از قبل با سیاست فعلی هم‌خوان‌اند.');
return Command::SUCCESS;
}
if ($dryRun) {
$io->note(sprintf('dry-run: %d بدنه تغییر می‌کرد. چیزی نوشته نشد.', count($changed)));
return Command::SUCCESS;
}
$this->connection->transactional(function (Connection $conn) use ($changed): void {
foreach ($changed as $row) {
$conn->executeStatement(
'UPDATE blogs SET body = :body WHERE id = :id',
['body' => $row['body'], 'id' => $row['id']],
);
}
});
$io->success(sprintf('%d بدنه پاک‌سازی و ذخیره شد.', count($changed)));
return Command::SUCCESS;
}
}