Files
Investidor-V2/app/Http/Controllers/InvestmentZoneAttachmentController.php
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

207 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentZone;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvestmentZoneAttachmentController extends Controller
{
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
public function store(Request $request, string $id): JsonResponse
{
$zone = InvestmentZone::whereKey($id)->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 !== null, 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 !== null, 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 !== null, 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, \stdClass $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.');
}
/** @param array<string,mixed> $metadata */
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(),
]);
}
}