304 lines
10 KiB
PHP
304 lines
10 KiB
PHP
<?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);
|
|
}
|
|
} |