Files
Investidor-V2/app/Services/VerifiedDocumentBinary.php
T

85 lines
4.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
/** Authorization/publication stays with the consumer; originals share integrity and cipher handling. */
class VerifiedDocumentBinary
{
public function respond(\stdClass $document, bool $inline = false): Response|BinaryFileResponse
{
$this->authorizeOpportunityAttachment($document);
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
&& ($document->deleted_at ?? null) === null && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
$disk = Storage::disk('local');
$root = realpath($disk->path(''));
$path = realpath($disk->path($document->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$headers = ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store'];
if ($inline) {
$headers['Content-Type'] = $document->media_type;
$headers['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'";
}
if (($document->storage_encryption ?? null) === 'laravel') {
try {
$bytes = Crypt::decryptString($disk->get($document->storage_key));
} catch (DecryptException) {
abort(404);
}
abort_unless(hash_equals(trim($document->sha256), hash('sha256', $bytes)), 404);
$headers['Content-Type'] = $document->media_type;
$headers['Content-Disposition'] = HeaderUtils::makeDisposition($inline ? 'inline' : 'attachment', basename($document->file_name), 'document');
return response($bytes, 200, $headers);
}
abort_unless(($document->storage_encryption ?? null) === null, 404);
$checksum = hash_file('sha256', $path);
abort_unless(is_string($checksum) && hash_equals(trim($document->sha256), $checksum), 404);
return $inline ? response()->file($path, $headers)->setContentDisposition('inline', basename($document->file_name)) : response()->download($path, basename($document->file_name), $headers);
}
/** The general document route must not bypass the opportunity's current authorization. */
public function authorizeOpportunityAttachment(\stdClass $document): void
{
$links = collect();
foreach (['opportunity_documents', 'opportunity_images'] as $table) {
if (! Schema::hasTable($table)) {
continue;
}
$query = DB::table($table)->where('document_id', $document->id);
if ($table === 'opportunity_images') {
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]);
}
$links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload']));
}
$privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
if (! $privateOrigin) {
$links = $links->reject(fn ($link) => isset(json_decode($link->payload, true)['library_source']));
}
if ($links->isEmpty() && ! $privateOrigin) {
return; // Other consumers retain their own publication/authorization rules.
}
$actor = request()->user();
abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.');
foreach ($links as $link) {
$opportunity = Opportunity::whereKey($link->opportunity_id)->first();
if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived'
&& app(PortalProfileAccess::class)->canView($actor, $opportunity)) {
return;
}
}
abort(403, 'O perfil atual não autoriza consultar este anexo da oportunidade.');
}
}