Files
SkinbaseNova/app/Services/Enhance/Processors/ExternalWorkerEnhanceProcessor.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);
}
}