Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance\Processors;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessor;
use App\Services\Enhance\EnhanceProcessorResult;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use RuntimeException;
use Throwable;
final class ExternalWorkerEnhanceProcessor implements EnhanceProcessor
{
private const SAFE_WORKER_ERRORS = [
'Worker is unavailable.',
'Worker token is missing.',
'Worker rejected the image.',
'Worker returned an invalid response.',
'The upscaled output exceeded the maximum allowed size.',
'The source file could not be downloaded by the worker.',
'Upscale engine is not available. Check model files and worker installation.',
'The enhance worker timed out while processing this image.',
];
public function __construct(
private readonly EnhanceStorageService $storage,
) {
}
public function process(EnhanceJob $job): EnhanceProcessorResult
{
$workerUrl = trim((string) config('enhance.external_worker.url', ''));
if ($workerUrl === '') {
throw new RuntimeException('Worker URL is missing.');
}
$token = trim((string) config('enhance.external_worker.token', ''));
if ($token === '') {
throw new RuntimeException('Worker token is missing.');
}
$timeout = max(1, (int) config('enhance.external_worker.timeout', 300));
$sourceUrl = $this->sourceUrlForWorker($job);
try {
$response = $this->http($timeout)
->post($this->workerEndpoint($workerUrl, '/v1/upscale'), [
'job_id' => (int) $job->id,
'source_url' => $sourceUrl,
'scale' => (int) $job->scale,
'mode' => (string) $job->mode,
'output_format' => 'webp',
]);
} catch (ConnectionException $exception) {
throw $this->wrapHttpException($exception, $job, 'upscale');
}
$payload = $this->decodeWorkerPayload($response);
[$binary, $cleanupFilename] = $this->resolveWorkerOutputBinary($payload, $workerUrl, $token, $timeout, $job);
$validated = $this->validateOutputBinary($binary);
$stored = $this->storage->putOutputBinary($job, $binary, $validated['mime']);
if ($cleanupFilename !== null) {
$this->deleteWorkerResult($workerUrl, $cleanupFilename, $token, $timeout, $job);
}
$metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$metadata['source_transport'] = str_contains($sourceUrl, '/internal/enhance/source/') ? 'signed-route' : 'temporary-url';
return new EnhanceProcessorResult(
disk: $stored['disk'],
path: $stored['path'],
width: (int) $validated['width'],
height: (int) $validated['height'],
filesize: (int) $validated['filesize'],
mime: (string) $validated['mime'],
metadata: $metadata,
);
}
private function http(int $timeout): PendingRequest
{
return Http::timeout($timeout)
->acceptJson()
->asJson()
->withToken((string) config('enhance.external_worker.token'));
}
private function decodeWorkerPayload(Response $response): array
{
if (! $response->successful()) {
$payload = $response->json();
throw new RuntimeException(
$response->status() >= 500
? 'Worker is unavailable.'
: $this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker rejected the image.'),
);
}
$payload = $response->json();
if (! is_array($payload) || ! ($payload['success'] ?? false)) {
throw new RuntimeException(
$this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker returned an invalid response.'),
);
}
return $payload;
}
private function resolveWorkerOutputBinary(array $payload, string $workerUrl, string $token, int $timeout, EnhanceJob $job): array
{
$base64 = trim((string) ($payload['output_base64'] ?? ''));
if ($base64 !== '') {
$binary = base64_decode($base64, true);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
return [$binary, null];
}
$outputUrl = trim((string) ($payload['output_url'] ?? ''));
if ($outputUrl === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
$safeOutputUrl = $this->normalizeWorkerOutputUrl($workerUrl, $outputUrl);
try {
$outputResponse = Http::timeout($timeout)
->withToken($token)
->get($safeOutputUrl);
} catch (ConnectionException $exception) {
throw $this->wrapHttpException($exception, $job, 'download');
}
if (! $outputResponse->successful()) {
throw new RuntimeException('Worker returned an invalid response.');
}
$binary = $outputResponse->body();
if ($binary === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
$path = trim((string) parse_url($safeOutputUrl, PHP_URL_PATH));
$filename = basename($path);
return [$binary, $filename !== '' ? $filename : null];
}
private function validateOutputBinary(string $binary): array
{
$maxBytes = max(1, (int) config('enhance.external_worker.max_download_mb', 60)) * 1024 * 1024;
if (strlen($binary) > $maxBytes) {
throw new RuntimeException('The upscaled output exceeded the maximum allowed size.');
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions)) {
throw new RuntimeException('Worker returned an invalid response.');
}
$width = (int) ($dimensions[0] ?? 0);
$height = (int) ($dimensions[1] ?? 0);
$maxWidth = max(1, (int) config('enhance.max_output_width', 8192));
$maxHeight = max(1, (int) config('enhance.max_output_height', 8192));
if ($width < 1 || $height < 1 || $width > $maxWidth || $height > $maxHeight) {
throw new RuntimeException('Worker returned an invalid response.');
}
$mime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: ''));
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
throw new RuntimeException('Worker returned an invalid response.');
}
return [
'width' => $width,
'height' => $height,
'filesize' => strlen($binary),
'mime' => $mime,
];
}
private function sourceUrlForWorker(EnhanceJob $job): string
{
$disk = Storage::disk($job->source_disk ?: $this->storage->diskName());
$path = ltrim(trim((string) $job->source_path), '/');
if ($path === '') {
throw new RuntimeException('The source file could not be downloaded by the worker.');
}
try {
if (method_exists($disk, 'providesTemporaryUrls') && $disk->providesTemporaryUrls()) {
return $disk->temporaryUrl($path, now()->addMinutes(15));
}
} catch (Throwable) {
}
return URL::temporarySignedRoute(
'enhance.source.download',
now()->addMinutes(15),
['enhanceJob' => $job->id],
);
}
private function normalizeWorkerOutputUrl(string $workerUrl, string $outputUrl): string
{
if (str_starts_with($outputUrl, '/')) {
return rtrim($workerUrl, '/') . $outputUrl;
}
$workerParts = parse_url($workerUrl);
$outputParts = parse_url($outputUrl);
if (! is_array($workerParts) || ! is_array($outputParts)) {
throw new RuntimeException('Worker returned an invalid response.');
}
$sameHost = ($workerParts['scheme'] ?? null) === ($outputParts['scheme'] ?? null)
&& ($workerParts['host'] ?? null) === ($outputParts['host'] ?? null)
&& (($workerParts['port'] ?? null) === ($outputParts['port'] ?? null));
if (! $sameHost) {
throw new RuntimeException('Worker returned an invalid response.');
}
return $outputUrl;
}
private function deleteWorkerResult(string $workerUrl, string $filename, string $token, int $timeout, EnhanceJob $job): void
{
$safeFilename = basename($filename);
if ($safeFilename === '') {
return;
}
try {
Http::timeout(min($timeout, 30))
->acceptJson()
->withToken($token)
->delete($this->workerEndpoint($workerUrl, '/v1/results/' . rawurlencode($safeFilename)));
} catch (ConnectionException $exception) {
Log::warning('enhance.external_worker.cleanup_failed', [
'enhance_job_id' => $job->id,
'message' => $exception->getMessage(),
]);
}
}
private function workerEndpoint(string $workerUrl, string $path): string
{
return rtrim($workerUrl, '/') . $path;
}
private function normalizeWorkerError(mixed $error, string $fallback): string
{
$message = trim((string) $error);
if (in_array($message, self::SAFE_WORKER_ERRORS, true)) {
return $message;
}
return $fallback;
}
private function wrapHttpException(ConnectionException $exception, EnhanceJob $job, string $stage): RuntimeException
{
$message = str_contains(strtolower($exception->getMessage()), 'timed out')
? 'The enhance worker timed out while processing this image.'
: 'Worker is unavailable.';
Log::warning('enhance.external_worker.http_failed', [
'enhance_job_id' => $job->id,
'stage' => $stage,
'message' => $exception->getMessage(),
]);
return new RuntimeException($message, 0, $exception);
}
}