284 lines
20 KiB
PHP
284 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Jobs\ScanDocumentForMalware;
|
||
use App\Models\Opportunity;
|
||
use App\Models\User;
|
||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Crypt;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Facades\Validator;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class OpportunityResources
|
||
{
|
||
public const KINDS = ['documents', 'images', 'locations'];
|
||
|
||
public function installed(): bool
|
||
{
|
||
return Schema::hasTable('opportunity_images');
|
||
}
|
||
|
||
public function table(string $kind): string
|
||
{
|
||
abort_unless(in_array($kind, self::KINDS, true), 404);
|
||
|
||
return 'opportunity_'.($kind === 'locations' ? 'location_drafts' : $kind);
|
||
}
|
||
|
||
/** @return list<\stdClass> */
|
||
public function listing(int $opportunityId, string $kind): array
|
||
{
|
||
$query = DB::table($this->table($kind))->where('opportunity_id', $opportunityId)->orderBy('created_at')->orderBy('id');
|
||
$rows = $query->get()->all();
|
||
foreach ($rows as $row) {
|
||
$row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
||
if ($kind !== 'locations') {
|
||
$row->file = DB::table('documents')->where('id', $row->document_id)->first(['file_name', 'media_type', 'size_bytes', 'sha256', 'malware_scan_status', 'malware_failure_reason', 'malware_engine']);
|
||
}
|
||
}
|
||
|
||
return array_values($rows);
|
||
}
|
||
|
||
/** @param array<string,mixed> $input */
|
||
public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): \stdClass
|
||
{
|
||
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
|
||
abort_unless($this->installed(), 503);
|
||
$table = $this->table($kind);
|
||
$data = Validator::make($input, [
|
||
'etag' => 'required|string|size:64', 'version' => ($id ? 'required' : 'nullable').'|integer|min:1',
|
||
'creation_key' => ($id ? 'nullable' : 'required').'|uuid', 'reason' => 'required|string|min:20|max:1000',
|
||
'payload' => 'present|array',
|
||
])->validate();
|
||
$payload = $this->validatePayload($kind, $data['payload'], $opportunityId);
|
||
$stored = null;
|
||
$documentId = null;
|
||
if ($file) {
|
||
[$stored, $documentId] = $this->store($kind, $file);
|
||
} elseif (! $id && $kind !== 'locations') {
|
||
throw ValidationException::withMessages(['file' => 'Selecione o ficheiro original.']);
|
||
}
|
||
try {
|
||
$result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId) {
|
||
$opportunity = $this->lock($opportunityId, $data['etag']);
|
||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
|
||
$record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null;
|
||
abort_if($id && ! $record, 404);
|
||
abort_if($record && ($record->deleted_at || (int) $record->version !== (int) $data['version']), 409, 'Recurso alterado ou eliminado; preserve o preenchimento e reveja a versão.');
|
||
$resourceId = $id ?? $data['creation_key'];
|
||
if ($record && $kind === 'images' && ! $file) {
|
||
$previous = json_decode($record->payload, true);
|
||
foreach (['derivatives', 'derived_from_sha256', 'processing_status'] as $computed) {
|
||
if (isset($previous[$computed])) {
|
||
$payload[$computed] = $previous[$computed];
|
||
}
|
||
}
|
||
}
|
||
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.');
|
||
$active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at');
|
||
abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.');
|
||
if ($kind === 'locations') {
|
||
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
|
||
foreach ($others as $other) {
|
||
$otherPayload = json_decode($other->payload, true);
|
||
$keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit'];
|
||
abort_if(array_map(fn ($key) => $payload[$key] ?? null, $keys) === array_map(fn ($key) => $otherPayload[$key] ?? null, $keys), 422, 'Esta localização, papel e unidade já existem.');
|
||
abort_if(($payload['is_primary'] ?? false) && ($otherPayload['is_primary'] ?? false), 422, 'Já existe localização principal; altere a anterior antes de escolher outra.');
|
||
}
|
||
}
|
||
if ($kind === 'images') {
|
||
foreach ((clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get() as $other) {
|
||
$p = json_decode($other->payload, true);
|
||
abort_if(($payload['is_cover'] ?? false) && ($p['is_cover'] ?? false), 422, 'Já existe uma capa; retire essa seleção antes de escolher outra.');
|
||
abort_if(isset($payload['sort_order'], $p['sort_order']) && $payload['sort_order'] === $p['sort_order'], 422, 'A ordem já está ocupada.');
|
||
}
|
||
}
|
||
if ($file) {
|
||
$hash = hash_file('sha256', Storage::disk('local')->path($stored));
|
||
$duplicate = DB::table($table.' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.opportunity_id', $opportunityId)->where('d.sha256', $hash)->when($id, fn ($q) => $q->where('r.id', '!=', $id))->exists();
|
||
if ($duplicate) {
|
||
throw ValidationException::withMessages(['file' => 'Os mesmos bytes já estão ligados a esta oportunidade; recupere o recurso, sem duplicar.']);
|
||
}
|
||
$original = Storage::disk('local')->get($stored);
|
||
abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)) === true, 500, 'Falha ao cifrar original.');
|
||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-resources', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored, 'storage_encryption' => 'laravel', 'sha256' => $hash, 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => $file->getSize(), 'uploaded_by' => $actor->id]);
|
||
}
|
||
$values = ['payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => $record ? $record->version + 1 : 1, 'updated_at' => now()];
|
||
if ($kind !== 'locations' && $documentId) {
|
||
$values['document_id'] = $documentId;
|
||
}
|
||
if ($record) {
|
||
DB::table($table)->where('id', $id)->update($values);
|
||
} else {
|
||
DB::table($table)->insert([...$values, 'id' => $resourceId, 'opportunity_id' => $opportunityId, 'created_by' => $actor->id, 'created_at' => now()]);
|
||
}
|
||
$after = DB::table($table)->where('id', $resourceId)->first();
|
||
$this->audit($actor, $kind, $record, $after, $data['reason'], 'saved');
|
||
$opportunity->management_version++;
|
||
$opportunity->save();
|
||
|
||
return $after;
|
||
});
|
||
} catch (\Throwable $error) {
|
||
if ($stored) {
|
||
Storage::disk('local')->delete($stored);
|
||
}
|
||
throw $error;
|
||
}
|
||
|
||
if ($documentId) {
|
||
try {
|
||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||
} catch (\Throwable) {
|
||
DB::table('documents')->where('id', $documentId)->where('malware_scan_status', '!=', 'clean')->update(['status' => 'quarantined', 'malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed', 'updated_at' => now()]);
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @param array<string,mixed> $input */
|
||
public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void
|
||
{
|
||
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
|
||
abort_unless($this->installed(), 503);
|
||
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:delete,restore', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
|
||
DB::transaction(function () use ($actor, $opportunityId, $kind, $id, $data) {
|
||
$opportunity = $this->lock($opportunityId, $data['etag']);
|
||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
|
||
$table = $this->table($kind);
|
||
$record = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first();
|
||
abort_unless($record !== null, 404);
|
||
abort_if((int) $record->version !== (int) $data['version'], 409, 'Versão alterada.');
|
||
abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.');
|
||
$payload = json_decode($record->payload, true);
|
||
if ($data['action'] === 'restore') {
|
||
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
|
||
$this->validatePayload($kind, $payload, $opportunityId);
|
||
// Restoration never silently reclaims a primary/cover/order occupied by another resource.
|
||
if ($kind === 'images') {
|
||
$payload['is_cover'] = false;
|
||
unset($payload['sort_order']);
|
||
}
|
||
if ($kind === 'locations') {
|
||
$payload['is_primary'] = false;
|
||
$keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit'];
|
||
foreach (DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $other) {
|
||
$otherPayload = json_decode($other->payload, true);
|
||
abort_if(array_map(fn ($key) => $payload[$key] ?? null, $keys) === array_map(fn ($key) => $otherPayload[$key] ?? null, $keys), 422, 'Outra localização ocupa esta combinação; reconcilie antes de restaurar.');
|
||
}
|
||
}
|
||
}
|
||
if ($kind === 'locations' && $data['action'] === 'delete') {
|
||
foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) {
|
||
abort_if((json_decode($image->payload, true)['location_id'] ?? null) === $id, 422, 'Uma imagem usa esta localização; corrija a ligação antes de excluir.');
|
||
}
|
||
}
|
||
if ($kind === 'documents' && $data['action'] === 'delete') {
|
||
foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) {
|
||
abort_if((json_decode($image->payload, true)['rights_document_id'] ?? null) === $id, 422, 'Uma imagem depende deste comprovativo; corrija a ligação antes de excluir.');
|
||
}
|
||
}
|
||
DB::table($table)->where('id', $id)->update(['deleted_at' => $data['action'] === 'delete' ? now() : null, 'version' => $record->version + 1, 'payload' => json_encode($payload), 'updated_at' => now()]);
|
||
$this->audit($actor, $kind, $record, DB::table($table)->where('id', $id)->first(), $data['reason'], $data['action']);
|
||
$opportunity->management_version++;
|
||
$opportunity->save();
|
||
});
|
||
}
|
||
|
||
private function lock(int $id, string $etag): Opportunity
|
||
{
|
||
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
|
||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure a oportunidade antes de editar recursos.');
|
||
if (! hash_equals(app(OpportunityManagement::class)->etag($record), $etag)) {
|
||
throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['Oportunidade alterada. Preserve os dados e recarregue antes de reconciliar.']]], 409));
|
||
}
|
||
|
||
return $record;
|
||
}
|
||
|
||
/** @param array<string,mixed> $payload
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function validatePayload(string $kind, array $payload, int $opportunityId): array
|
||
{
|
||
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
|
||
if ($kind === 'locations') {
|
||
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]];
|
||
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => 'nullable|in:sqm,ha,sqkm', 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
|
||
} elseif ($kind === 'images') {
|
||
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
|
||
} else {
|
||
$rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'];
|
||
}
|
||
$data = Validator::make($payload, $rules)->validate();
|
||
if ($kind === 'locations') {
|
||
app(InvestmentGeography::class)->selection($data, true);
|
||
if (! empty($data['geometry'])) {
|
||
$geometry = $data['geometry'];
|
||
if (! in_array($geometry['type'] ?? '', ['Point', 'LineString', 'Polygon', 'MultiPolygon'], true) || isset($geometry['crs'])) {
|
||
throw ValidationException::withMessages(['geometry' => 'Use GeoJSON Point/LineString/Polygon/MultiPolygon EPSG:4326.']);
|
||
}
|
||
try {
|
||
$check = DB::selectOne('WITH g AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) AS geom) SELECT ST_IsValid(geom) AS valid, ST_IsEmpty(geom) AS empty, ST_XMin(Box3D(geom)) AS xmin,ST_XMax(Box3D(geom)) AS xmax,ST_YMin(Box3D(geom)) AS ymin,ST_YMax(Box3D(geom)) AS ymax FROM g', [json_encode($geometry)]);
|
||
if (! $check->valid || $check->empty || $check->xmin < -180 || $check->xmax > 180 || $check->ymin < -90 || $check->ymax > 90) {
|
||
throw new \RuntimeException;
|
||
}
|
||
} catch (\Throwable) {
|
||
throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora de EPSG:4326.']);
|
||
}
|
||
$data['spatial_compatibility'] = 'pending_official_boundary';
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
/** @return array{string,string} */
|
||
private function store(string $kind, UploadedFile $file): array
|
||
{
|
||
$mimes = $kind === 'images' ? 'image/jpeg,image/png,image/webp' : 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/png,image/jpeg';
|
||
Validator::make(['file' => $file], ['file' => ['required', 'file', 'mimetypes:'.$mimes, 'max:'.($kind === 'images' ? 10240 : 20480)]])->validate();
|
||
if (str_starts_with((string) $file->getMimeType(), 'image/')) {
|
||
$size = @getimagesize($file->getRealPath());
|
||
if (! $size || $size[0] < 400 || $size[1] < 400) {
|
||
throw ValidationException::withMessages(['file' => 'Imagem inválida ou inferior a 400 × 400 píxeis.']);
|
||
}
|
||
$bytes = file_get_contents($file->getRealPath()) ?: '';
|
||
if (preg_match('/<\?(?:php|=)|<script\b/i', $bytes) || (str_contains($bytes, 'acTL') && $file->getMimeType() === 'image/png') || (str_contains($bytes, 'ANIM') && $file->getMimeType() === 'image/webp')) {
|
||
throw ValidationException::withMessages(['file' => 'Imagem animada ou com conteúdo executável não permitida.']);
|
||
}
|
||
}
|
||
if (str_contains((string) $file->getMimeType(), 'openxmlformats')) {
|
||
$zip = new \ZipArchive;
|
||
if ($zip->open($file->getRealPath()) !== true) {
|
||
throw ValidationException::withMessages(['file' => 'Pacote Office inválido.']);
|
||
}
|
||
$required = str_contains($file->getMimeType(), 'wordprocessingml') ? 'word/document.xml' : 'xl/workbook.xml';
|
||
$valid = $zip->locateName($required) !== false && $zip->locateName('[Content_Types].xml') !== false;
|
||
$zip->close();
|
||
if (! $valid) {
|
||
throw ValidationException::withMessages(['file' => 'Conteúdo Office não corresponde ao formato indicado.']);
|
||
}
|
||
}
|
||
$id = (string) Str::uuid();
|
||
$key = $file->storeAs('opportunity-resources', $id, 'local');
|
||
abort_unless(is_string($key), 500, 'Falha ao guardar original privado.');
|
||
|
||
return [$key, $id];
|
||
}
|
||
|
||
private function audit(User $actor, string $kind, ?\stdClass $before, \stdClass $after, string $reason, string $action): void
|
||
{
|
||
DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => $kind, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($before, JSON_THROW_ON_ERROR))]), 'after_data' => json_encode(['opportunity_id' => $after->opportunity_id, 'resource_id' => $after->id, 'version' => $after->version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]);
|
||
}
|
||
}
|