firstOrFail(); $data = $request->validate([ 'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'], 'kind' => ['required', 'in:legal_instrument,map,photo,study,other'], 'caption' => ['required', 'string', 'max:255'], 'source_reference' => ['required', 'string', 'min:5', 'max:2000'], 'source_date' => ['nullable', 'date_format:Y-m-d'], 'rights_status' => ['required', 'in:pending,confirmed,restricted'], 'rights_basis' => ['nullable', 'string', 'max:2000'], ]); $file = $request->file('file'); abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.'); $this->validateFileContent($file->getRealPath(), $file->getMimeType()); abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.'); $attachmentId = (string) Str::uuid(); $documentId = (string) Str::uuid(); $key = null; try { $key = $file->storeAs('investment-zones/'.$id, $documentId, 'local'); abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.'); $hash = hash_file('sha256', Storage::disk('local')->path($key)); abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.'); $attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object { DB::table('documents')->insert([ 'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1, 'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash, // Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined. 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('investment_zone_attachments')->insert([ 'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId, 'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'], 'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'], 'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown', 'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now(), ]); $this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]); return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']); }); } catch (\Throwable $exception) { if (is_string($key)) { Storage::disk('local')->delete($key); } throw $exception; } ScanDocumentForMalware::dispatch($documentId)->afterCommit(); return response()->json($attachment, 201); } public function review(Request $request, string $id, string $action): JsonResponse { abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404); $rules = ['version_no' => ['required', 'integer', 'min:1']]; if ($action === 'review') { $rules += ['decision' => ['required', 'in:reviewed,rejected'], 'personal_data_status' => ['required', 'in:none,present,unknown'], 'rights_status' => ['required', 'in:pending,confirmed,restricted'], 'rights_basis' => ['nullable', 'string', 'max:2000']]; } $data = $request->validate($rules); if ($action === 'select-public' || $action === 'withdraw-public') { InvestmentZoneAccess::requireRole($request->user(), 'publishers'); } return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse { $attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first(); abort_unless($attachment, 404); abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.'); $version = (int) $attachment->version_no + 1; $changes = ['version_no' => $version, 'updated_at' => now()]; $metadata = []; if ($action === 'review') { abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.'); $changes += [ 'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(), 'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id, 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'], 'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis, 'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null, ]; $metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']]; } elseif ($action === 'select-public') { abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed' && trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.'); abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active') ->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422, 'A análise antimalware tem de terminar sem ameaça antes da divulgação.'); abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.'); $changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id]; } else { $changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null]; } DB::table('investment_zone_attachments')->where('id', $id)->update($changes); $this->event($id, $request->user()->id, $action, $version, $metadata); return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason'])); }); } public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse { $record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at') ->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256') ->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']); abort_unless($record, 404); return $this->verifiedDownload($zoneId, $record); } public function downloadPublic(string $zoneId, string $id): BinaryFileResponse { abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404); $record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true) ->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none') ->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean') ->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']); abort_unless($record, 404); $extension = match ($record->media_type) { 'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin' }; return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension); } private function verifiedDownload(string $zoneId, object $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse { $root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId)); $path = realpath(Storage::disk('local')->path($document->storage_key)); abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404); $hash = hash_file('sha256', $path); abort_unless(is_string($hash) && hash_equals(trim($document->sha256), $hash) && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); $headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']; $safeName = $downloadName ?? basename($document->file_name); return $inline ? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"']) : response()->download($path, $safeName, $headers); } private function validateFileContent(string $path, string $mime): void { if ($mime === 'application/pdf') { $handle = fopen($path, 'rb'); $signature = $handle ? fread($handle, 5) : false; if (is_resource($handle)) { fclose($handle); } abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.'); return; } $image = @getimagesize($path); abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.'); $expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null; abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.'); } private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void { DB::table('investment_zone_attachment_events')->insert([ 'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor, 'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(), ]); } }