207 lines
13 KiB
PHP
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(),
|
|
]);
|
|
}
|
|
}
|