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:
@@ -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 (` ` → 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user