Files
Investidor-V2/app/Services/OpportunityImageProcessor.php
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

123 lines
8.3 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class OpportunityImageProcessor
{
public function available(): bool
{
return function_exists('imagecreatefromstring') && function_exists('imagewebp');
}
/** @param array<string,mixed> $input */
public function prepare(User $actor, int $opportunityId, string $id, array $input): void
{
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
abort_unless($this->available(), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.');
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate();
$stored = [];
$documents = [];
try {
DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents) {
$o = Opportunity::withTrashed()->whereKey($opportunityId)->lockForUpdate()->firstOrFail();
app(PortalProfileAccess::class)->authorize($actor, 'edit', $o);
abort_if($o->trashed() || $o->status === 'archived', 409, 'Restaure antes de processar.');
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($o), $data['etag']), 409, 'Conflito de versão; preserve os dados.');
$image = DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($image !== null, 404);
abort_unless((int) $image->version === (int) $data['version'], 409, 'Imagem alterada.');
$doc = DB::table('documents')->where('id', $image->document_id)->first();
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan do original pendente ou inseguro.');
$disk = Storage::disk('local');
$bytes = $doc->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($doc->storage_key)) : $disk->get($doc->storage_key);
abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.');
$size = @getimagesizefromstring($bytes);
abort_unless($size && $size[0] >= 400 && $size[1] >= 400 && $size[0] * $size[1] <= 40000000, 422, 'Dimensões inválidas ou imagem excessiva para processamento seguro.');
$previousPayload = json_decode($image->payload, true, 512, JSON_THROW_ON_ERROR);
$previousDerivatives = $previousPayload['derivatives'] ?? [];
$expectedWidths = array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280]));
if (($previousPayload['derived_from_sha256'] ?? null) === trim($doc->sha256)
&& array_values(array_column($previousDerivatives, 'width')) === array_values($expectedWidths)) {
$reusable = true;
$retry = [];
foreach ($previousDerivatives as $derivative) {
$existing = DB::table('documents')->where('id', $derivative['document_id'])->whereNull('deleted_at')->first();
if (! $existing || $existing->source_system !== 'opportunity-image-derivatives' || ! $disk->exists($existing->storage_key)) {
$reusable = false;
break;
}
$existingBytes = Crypt::decryptString($disk->get($existing->storage_key));
if (! hash_equals(trim($existing->sha256), hash('sha256', $existingBytes))) {
$reusable = false;
break;
}
if ($existing->malware_scan_status !== 'clean') {
$retry[] = $existing->id;
}
}
if ($reusable) {
$documents = $retry;
return; // Retry scans of the same streams, without new files, versions or audit decisions.
}
}
$decoded = @imagecreatefromstring($bytes);
abort_unless($decoded !== false, 422, 'Não foi possível descodificar a imagem.');
$derivatives = [];
try {
foreach (array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280])) as $width) {
$w = min($width, $size[0]);
$h = max(1, (int) round($size[1] * $w / $size[0]));
$canvas = imagecreatetruecolor($w, $h);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagecopyresampled($canvas, $decoded, 0, 0, 0, 0, $w, $h, $size[0], $size[1]);
ob_start();
$encoded = imagewebp($canvas, null, 85);
$webp = ob_get_clean();
imagedestroy($canvas);
abort_unless($encoded && is_string($webp), 500, 'Falha ao gerar WebP.');
$documentId = (string) Str::uuid();
$key = 'opportunity-resources/'.$documentId;
abort_unless($disk->put($key, Crypt::encryptString($webp)) === true, 500, 'Falha ao guardar derivada cifrada.');
$stored[] = $key;
$documents[] = $documentId;
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-image-derivatives', 'file_name' => 'image-'.$width.'.webp', 'media_type' => 'image/webp', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'sha256' => hash('sha256', $webp), 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => strlen($webp), 'uploaded_by' => $actor->id]);
$derivatives[] = ['document_id' => $documentId, 'width' => $w, 'height' => $h];
}
} finally {
imagedestroy($decoded);
}
$payload = json_decode($image->payload, true);
$payload['derivatives'] = $derivatives;
$payload['derived_from_sha256'] = trim($doc->sha256);
$payload['processing_status'] = 'generated_without_original_metadata';
DB::table('opportunity_images')->where('id', $id)->update(['payload' => json_encode($payload), 'version' => $image->version + 1, 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'images', 'action' => 'derivatives_prepared', 'actor_user_id' => $actor->id, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($image, JSON_THROW_ON_ERROR))]), 'after_data' => json_encode(['opportunity_id' => $opportunityId, 'resource_id' => $id, 'version' => $image->version + 1, 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode(DB::table('opportunity_images')->where('id', $id)->first(), JSON_THROW_ON_ERROR))])]);
$o->management_version++;
$o->save();
});
} catch (\Throwable $error) {
foreach ($stored as $key) {
Storage::disk('local')->delete($key);
}
throw $error;
}
// Each new byte stream receives its own scan; no automatic clean result inherited from the original.
foreach ($documents as $documentId) {
try {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
} catch (\Throwable) {
DB::table('documents')->where('id', $documentId)->update(['malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed']);
}
}
}
}