$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 = []; $scanContext = null; try { DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents, &$scanContext) { $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_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; $scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $o); 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; $stored[] = $key; abort_unless($disk->put($key, Crypt::encryptString($webp)) === true, 500, 'Falha ao guardar derivada cifrada.'); $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(); $scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $o); }); } catch (\Throwable $error) { app(OpportunityResourceFileCleanup::class)->afterFailure($stored, $error); throw $error; } // Each new byte stream receives its own scan; no automatic clean result inherited from the original. foreach ($documents as $documentId) { app(OpportunityScanDispatch::class)->dispatch($documentId, $scanContext); } } }