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,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\EnhanceJob;
interface EnhanceProcessor
{
public function process(EnhanceJob $job): EnhanceProcessorResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
use App\Services\Enhance\Processors\StubEnhanceProcessor;
use RuntimeException;
final class EnhanceProcessorFactory
{
public function __construct(
private readonly StubEnhanceProcessor $stubProcessor,
private readonly ExternalWorkerEnhanceProcessor $externalWorkerProcessor,
) {
}
public function make(string $engine): EnhanceProcessor
{
return match ($engine) {
EnhanceJob::ENGINE_STUB => $this->stubProcessor,
EnhanceJob::ENGINE_EXTERNAL_WORKER => $this->externalWorkerProcessor,
default => throw new RuntimeException('Unknown enhance processor engine.'),
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
final class EnhanceProcessorResult
{
public function __construct(
public readonly string $disk,
public readonly string $path,
public readonly int $width,
public readonly int $height,
public readonly int $filesize,
public readonly string $mime,
public readonly ?array $metadata = null,
) {
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
final class EnhanceService
{
public function __construct(
private readonly EnhanceValidator $validator,
private readonly EnhanceStorageService $storage,
) {
}
public function createFromUpload(User $user, UploadedFile $file, array $options): EnhanceJob
{
$this->assertCreationAllowed($user);
$this->assertDailyLimit($user);
$validated = $this->validator->validateUpload($file, $options);
$source = $this->storage->storeUploadedSource($user, $file);
$job = DB::transaction(function () use ($user, $validated, $source): EnhanceJob {
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
'user_id' => (int) $user->id,
'status' => EnhanceJob::STATUS_PENDING,
]);
$this->queue($enhanceJob);
return $enhanceJob->fresh();
});
Log::info('enhance.job.created', [
'enhance_job_id' => $job->id,
'user_id' => $user->id,
'type' => 'upload',
'engine' => $job->engine,
]);
return $job;
}
public function createFromArtwork(User $user, Artwork $artwork, array $options): EnhanceJob
{
$this->assertCreationAllowed($user);
$this->assertDailyLimit($user);
$artworkSource = $this->storage->fetchArtworkSource($artwork);
$validated = $this->validator->validateBinary(
$artworkSource['binary'],
$options,
(int) ($artwork->file_size ?? strlen((string) $artworkSource['binary'])),
);
$source = $this->storage->storeSourceBinary($user, (string) $artworkSource['binary'], (string) $artworkSource['extension']);
$job = DB::transaction(function () use ($user, $artwork, $validated, $source): EnhanceJob {
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
'user_id' => (int) $user->id,
'artwork_id' => (int) $artwork->id,
'status' => EnhanceJob::STATUS_PENDING,
]);
$this->queue($enhanceJob);
return $enhanceJob->fresh();
});
Log::info('enhance.job.created', [
'enhance_job_id' => $job->id,
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'type' => 'artwork',
'engine' => $job->engine,
]);
return $job;
}
public function retry(EnhanceJob $job): EnhanceJob
{
Log::info('enhance.retry.started', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
'status' => $job->status,
]);
if (! $job->isFailed()) {
throw ValidationException::withMessages([
'job' => 'Only failed enhance jobs can be retried.',
]);
}
if (! $this->sourceExists($job)) {
Log::warning('enhance.retry.failed_missing_source', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
]);
throw ValidationException::withMessages([
'job' => 'This enhance job can no longer be retried because the original source file was cleaned up.',
]);
}
DB::transaction(function () use ($job): void {
$this->storage->deleteGeneratedFiles($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$retryCount = max(0, (int) ($metadata['retry_count'] ?? 0)) + 1;
$job->forceFill([
'status' => EnhanceJob::STATUS_QUEUED,
'output_disk' => null,
'output_path' => null,
'output_hash' => null,
'output_width' => null,
'output_height' => null,
'output_filesize' => null,
'output_mime' => null,
'preview_disk' => null,
'preview_path' => null,
'processing_seconds' => null,
'error_message' => null,
'started_at' => null,
'finished_at' => null,
'queued_at' => now(),
'metadata' => array_merge($metadata, [
'retry_count' => $retryCount,
'last_retried_at' => now()->toIso8601String(),
]),
])->save();
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
});
Log::info('enhance.retry.dispatched', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
'retry_count' => (int) (($job->fresh()?->metadata['retry_count'] ?? 0)),
]);
return $job->fresh();
}
public function markFailedByModerator(EnhanceJob $job, User $actor): EnhanceJob
{
if (! in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true)) {
throw ValidationException::withMessages([
'job' => 'Only pending, queued, or processing jobs can be marked as failed.',
]);
}
$metadata = is_array($job->metadata) ? $job->metadata : [];
DB::transaction(function () use ($job, $actor, $metadata): void {
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => 'Marked as failed by moderator.',
'finished_at' => now(),
'processing_seconds' => $job->started_at ? max(0, now()->diffInSeconds($job->started_at)) : $job->processing_seconds,
'metadata' => array_merge($metadata, [
'moderation' => [
'marked_failed_at' => now()->toIso8601String(),
'marked_failed_by' => (int) $actor->id,
],
]),
])->save();
});
Log::info('enhance.moderation.mark_failed', [
'enhance_job_id' => $job->id,
'moderator_id' => $actor->id,
]);
return $job->fresh();
}
public function delete(EnhanceJob $job): void
{
DB::transaction(function () use ($job): void {
$this->storage->deleteFiles($job);
$job->delete();
});
Log::info('enhance.job.deleted', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
]);
}
private function assertCreationAllowed(User $user): void
{
if (method_exists($user, 'hasVerifiedEmail') && ! $user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'image' => 'Please verify your email address before using Skinbase Enhance.',
]);
}
}
private function assertDailyLimit(User $user): void
{
$limit = max(0, (int) config('enhance.daily_limit', 10));
if ($limit === 0) {
return;
}
$count = EnhanceJob::query()
->where('user_id', (int) $user->id)
->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()])
->count();
if ($count >= $limit) {
throw ValidationException::withMessages([
'image' => 'You have reached your daily enhance limit. Please try again tomorrow.',
]);
}
}
private function queue(EnhanceJob $job): void
{
$job->forceFill([
'status' => EnhanceJob::STATUS_QUEUED,
'queued_at' => now(),
])->save();
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
}
public function frontendConfig(): array
{
$engine = (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB);
$showStubWarning = (bool) config('enhance.stub.show_warning', true) && $engine === EnhanceJob::ENGINE_STUB;
return [
'engine' => $engine,
'isStub' => $engine === EnhanceJob::ENGINE_STUB,
'showStubWarning' => $showStubWarning,
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
'allowedModes' => array_values((array) config('enhance.allowed_modes', [])),
'allowedScales' => array_map('intval', (array) config('enhance.allowed_scales', [])),
];
}
private function sourceExists(EnhanceJob $job): bool
{
$path = ltrim(trim((string) $job->source_path), '/');
if ($path === '') {
return false;
}
return Storage::disk($job->source_disk ?: $this->storage->diskName())->exists($path);
}
}

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\ArtworkOriginalFileLocator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class EnhanceStorageService
{
private ?ImageManager $manager = null;
public function __construct(
private readonly ArtworkOriginalFileLocator $artworkOriginalFileLocator,
) {
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function diskName(): string
{
return (string) config('enhance.disk', 'public');
}
public function fetchSourceBinary(EnhanceJob $job): string
{
$path = trim((string) $job->source_path);
if ($path === '') {
throw new RuntimeException('Enhance source image is missing.');
}
$contents = Storage::disk($job->source_disk ?: $this->diskName())->get($path);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException('Unable to read enhance source image.');
}
return $contents;
}
public function fetchArtworkSource(Artwork $artwork): array
{
$objectPath = $this->artworkOriginalFileLocator->resolveObjectPath($artwork);
if ($objectPath === '') {
throw new RuntimeException('Artwork source file is unavailable for enhance.');
}
$disk = (string) config('uploads.object_storage.disk', 's3');
$contents = Storage::disk($disk)->get($objectPath);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException('Unable to read the original artwork source.');
}
$extension = strtolower(ltrim((string) ($artwork->file_ext ?? pathinfo($objectPath, PATHINFO_EXTENSION)), '.'));
$mime = trim(strtolower((string) ($artwork->mime_type ?? '')));
if ($mime === '') {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($contents));
}
return [
'disk' => $disk,
'path' => $objectPath,
'binary' => $contents,
'mime' => $mime,
'extension' => $extension !== '' ? $extension : $this->extensionFromMime($mime),
];
}
public function storeUploadedSource(User $user, UploadedFile $file): array
{
$path = (string) ($file->getRealPath() ?: $file->getPathname());
if ($path === '' || ! is_readable($path)) {
throw new RuntimeException('Unable to resolve uploaded source path.');
}
$binary = file_get_contents($path);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Unable to read uploaded source image.');
}
$extension = strtolower(ltrim((string) ($file->getClientOriginalExtension() ?: $file->extension()), '.'));
return $this->storeSourceBinary($user, $binary, $extension !== '' ? $extension : 'bin');
}
public function storeSourceBinary(User $user, string $binary, string $extension): array
{
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($binary));
$normalizedExtension = $extension !== '' ? $extension : $this->extensionFromMime($mime);
$relativePath = $this->buildPath((string) config('enhance.source_prefix', 'enhance/sources'), (int) $user->id, sprintf('%s.%s', Str::uuid()->toString(), $normalizedExtension));
$this->writeBinary($this->diskName(), $relativePath, $binary, $mime);
return [
'source_disk' => $this->diskName(),
'source_path' => $relativePath,
'source_hash' => hash('sha256', $binary),
];
}
public function putOutputBinary(EnhanceJob $job, string $binary, string $mime, ?string $extension = null): array
{
$normalizedMime = strtolower(trim($mime));
$ext = $extension !== null && $extension !== '' ? strtolower(ltrim($extension, '.')) : $this->extensionFromMime($normalizedMime);
$filename = sprintf('%s_x%d.%s', Str::uuid()->toString(), (int) $job->scale, $ext);
$relativePath = $this->buildPath((string) config('enhance.output_prefix', 'enhance/outputs'), (int) $job->user_id, $filename);
$this->writeBinary($this->diskName(), $relativePath, $binary, $normalizedMime);
$dimensions = @getimagesizefromstring($binary) ?: [0, 0];
return [
'disk' => $this->diskName(),
'path' => $relativePath,
'hash' => hash('sha256', $binary),
'width' => (int) ($dimensions[0] ?? 0),
'height' => (int) ($dimensions[1] ?? 0),
'filesize' => strlen($binary),
'mime' => $normalizedMime,
];
}
public function storePreviewFromBinary(EnhanceJob $job, string $binary): ?array
{
$previewBinary = $binary;
$previewMime = 'image/webp';
if ($this->manager !== null) {
try {
$previewBinary = (string) $this->manager
->read($binary)
->scaleDown(width: 1600, height: 1600)
->encode(new WebpEncoder(82));
} catch (\Throwable) {
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
$previewBinary = $binary;
}
} else {
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
}
$extension = $this->extensionFromMime($previewMime);
$relativePath = $this->buildPath(
(string) config('enhance.preview_prefix', 'enhance/previews'),
(int) $job->user_id,
sprintf('%s_preview.%s', Str::uuid()->toString(), $extension),
);
$this->writeBinary($this->diskName(), $relativePath, $previewBinary, $previewMime);
return [
'preview_disk' => $this->diskName(),
'preview_path' => $relativePath,
];
}
public function createPreviewFromStoredOutput(EnhanceJob $job, string $disk, string $path): ?array
{
$contents = Storage::disk($disk)->get($path);
if (! is_string($contents) || $contents === '') {
return null;
}
return $this->storePreviewFromBinary($job, $contents);
}
public function deleteFiles(EnhanceJob $job): void
{
$this->deleteFilesForJob($job);
}
public function deleteGeneratedFiles(EnhanceJob $job): void
{
foreach ([
[$job->output_disk, $job->output_path],
[$job->preview_disk, $job->preview_path],
] as [$disk, $path]) {
$this->safeDelete($disk, $path);
}
}
public function deleteFilesForJob(EnhanceJob $job): array
{
$result = [
'deleted' => [
'source' => false,
'output' => false,
'preview' => false,
],
'skipped' => [],
'errors' => [],
];
foreach ([
'source' => [$job->source_disk, $job->source_path],
'output' => [$job->output_disk, $job->output_path],
'preview' => [$job->preview_disk, $job->preview_path],
] as $key => [$disk, $path]) {
try {
$deleted = $this->safeDelete($disk, $path);
$result['deleted'][$key] = $deleted;
if (! $deleted && trim((string) $path) !== '') {
$result['skipped'][] = $key;
}
} catch (\Throwable $exception) {
$result['errors'][$key] = $exception->getMessage();
Log::warning('enhance.cleanup.file_delete_failed', [
'path' => trim((string) $path),
'disk' => $disk ?: $this->diskName(),
'message' => $exception->getMessage(),
]);
}
}
return $result;
}
public function isEnhancePath(?string $path): bool
{
$trimmedPath = ltrim(trim((string) $path), '/');
if ($trimmedPath === '') {
return false;
}
foreach ($this->enhancePrefixes() as $prefix) {
if ($trimmedPath === $prefix || str_starts_with($trimmedPath, $prefix . '/')) {
return true;
}
}
return false;
}
public function safeDelete(?string $disk, ?string $path): bool
{
$trimmedPath = ltrim(trim((string) $path), '/');
if ($trimmedPath === '') {
return false;
}
if (! $this->isEnhancePath($trimmedPath)) {
Log::warning('enhance.cleanup.file_skipped', [
'path' => $trimmedPath,
'disk' => $disk ?: $this->diskName(),
'reason' => 'outside-enhance-prefixes',
]);
return false;
}
$targetDisk = $disk ?: $this->diskName();
if (! Storage::disk($targetDisk)->exists($trimmedPath)) {
return false;
}
$deleted = Storage::disk($targetDisk)->delete($trimmedPath);
if ($deleted) {
Log::info('enhance.cleanup.file_deleted', [
'path' => $trimmedPath,
'disk' => $targetDisk,
]);
return true;
}
Log::warning('enhance.cleanup.file_delete_failed', [
'path' => $trimmedPath,
'disk' => $targetDisk,
'message' => 'Storage delete returned false.',
]);
return false;
}
public function listKnownJobPaths(): array
{
return EnhanceJob::withTrashed()
->get(['source_path', 'output_path', 'preview_path'])
->flatMap(fn (EnhanceJob $job): array => array_values(array_filter([
ltrim(trim((string) $job->source_path), '/'),
ltrim(trim((string) $job->output_path), '/'),
ltrim(trim((string) $job->preview_path), '/'),
])))
->unique()
->values()
->all();
}
private function buildPath(string $prefix, int $userId, string $filename): string
{
return sprintf(
'%s/%d/%s/%s/%s',
trim($prefix, '/'),
$userId,
now()->format('Y'),
now()->format('m'),
ltrim($filename, '/'),
);
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function extensionFromMime(string $mime): string
{
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'bin',
};
}
private function writeBinary(string $disk, string $path, string $binary, string $mime): void
{
$written = Storage::disk($disk)->put($path, $binary, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime,
]);
if ($written !== true) {
throw new RuntimeException('Unable to store enhance image in storage.');
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
final class EnhanceValidator
{
public function validateUpload(UploadedFile $file, array $options): array
{
$path = (string) ($file->getRealPath() ?: $file->getPathname());
if ($path === '' || ! is_readable($path)) {
throw ValidationException::withMessages([
'image' => 'Unable to read the uploaded image.',
]);
}
$binary = file_get_contents($path);
if (! is_string($binary) || $binary === '') {
throw ValidationException::withMessages([
'image' => 'Unable to read the uploaded image.',
]);
}
return $this->validateBinary($binary, $options, (int) ($file->getSize() ?? strlen($binary)));
}
public function validateBinary(string $binary, array $options, ?int $filesize = null): array
{
$normalized = $this->normalizeOptions($options);
$size = $filesize ?? strlen($binary);
if ($size <= 0) {
throw ValidationException::withMessages([
'image' => 'Uploaded image is empty.',
]);
}
$maxBytes = (int) config('enhance.max_upload_mb', 20) * 1024 * 1024;
if ($maxBytes > 0 && $size > $maxBytes) {
throw ValidationException::withMessages([
'image' => sprintf('The image may not be greater than %d MB.', (int) config('enhance.max_upload_mb', 20)),
]);
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($binary));
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
throw ValidationException::withMessages([
'image' => 'Unsupported image format. Upload a JPEG, PNG, or WebP image.',
]);
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions) || (int) ($dimensions[0] ?? 0) < 1 || (int) ($dimensions[1] ?? 0) < 1) {
throw ValidationException::withMessages([
'image' => 'Uploaded file is not a valid image.',
]);
}
$width = (int) ($dimensions[0] ?? 0);
$height = (int) ($dimensions[1] ?? 0);
if ($width > (int) config('enhance.max_input_width', 4096)) {
throw ValidationException::withMessages([
'image' => sprintf('Image width may not exceed %d pixels.', (int) config('enhance.max_input_width', 4096)),
]);
}
if ($height > (int) config('enhance.max_input_height', 4096)) {
throw ValidationException::withMessages([
'image' => sprintf('Image height may not exceed %d pixels.', (int) config('enhance.max_input_height', 4096)),
]);
}
return $normalized + [
'input_width' => $width,
'input_height' => $height,
'input_filesize' => $size,
'input_mime' => $mime,
];
}
public function normalizeOptions(array $options): array
{
$allowedScales = array_map('intval', (array) config('enhance.allowed_scales', [2, 4]));
$allowedModes = array_map('strval', (array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']));
$allowedEngines = [
\App\Models\EnhanceJob::ENGINE_STUB,
\App\Models\EnhanceJob::ENGINE_EXTERNAL_WORKER,
];
$scale = (int) ($options['scale'] ?? config('enhance.allowed_scales.0', 2));
$mode = trim((string) ($options['mode'] ?? 'standard'));
$engine = trim((string) ($options['engine'] ?? config('enhance.default_engine', \App\Models\EnhanceJob::ENGINE_STUB)));
if (! in_array($scale, $allowedScales, true)) {
throw ValidationException::withMessages([
'scale' => 'Please select a supported scale.',
]);
}
if (! in_array($mode, $allowedModes, true)) {
throw ValidationException::withMessages([
'mode' => 'Please select a supported enhance mode.',
]);
}
if (! in_array($engine, $allowedEngines, true)) {
throw ValidationException::withMessages([
'engine' => 'Please select a supported enhance engine.',
]);
}
return [
'scale' => $scale,
'mode' => $mode,
'engine' => $engine,
];
}
}

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);
}
}

View File

@@ -0,0 +1,75 @@
<?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 Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
final class StubEnhanceProcessor implements EnhanceProcessor
{
private ?ImageManager $manager = null;
public function __construct(
private readonly EnhanceStorageService $storage,
) {
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function process(EnhanceJob $job): EnhanceProcessorResult
{
$sourceBinary = $this->storage->fetchSourceBinary($job);
$outputBinary = $sourceBinary;
$outputMime = (string) ($job->input_mime ?: 'image/jpeg');
$scale = max(1, (int) $job->scale);
$metadata = [
'stub' => true,
'engine' => EnhanceJob::ENGINE_STUB,
'requested_scale' => $scale,
];
if ($this->manager !== null) {
try {
$image = $this->manager->read($sourceBinary);
$targetWidth = max((int) $image->width(), (int) $image->width() * $scale);
$targetHeight = max((int) $image->height(), (int) $image->height() * $scale);
$outputBinary = (string) $image
->resize($targetWidth, $targetHeight)
->encode(new WebpEncoder(88));
$outputMime = 'image/webp';
$metadata['actual_scale'] = $scale;
} catch (\Throwable) {
$metadata['actual_scale'] = 1;
$metadata['fallback'] = 'source-copy';
}
} else {
$metadata['actual_scale'] = 1;
$metadata['fallback'] = 'source-copy';
}
$stored = $this->storage->putOutputBinary($job, $outputBinary, $outputMime);
return new EnhanceProcessorResult(
disk: $stored['disk'],
path: $stored['path'],
width: (int) $stored['width'],
height: (int) $stored['height'],
filesize: (int) $stored['filesize'],
mime: (string) $stored['mime'],
metadata: $metadata,
);
}
}