Files
compras/app/Services/CandidateDocuments.php
T
2026-10-30 20:52:10 +02:00

124 lines
5.5 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipArchive;
class CandidateDocuments
{
/** @param array<string,mixed> $files
* @return array<string,array<string,mixed>>
*/
public function fingerprint(array $files): array
{
$result = [];
$total = 0;
foreach ($files as $key => $file) {
Validator::make(['file' => $file], ['file' => 'required|file|min:0.001|max:51200'])->validate();
abort_unless($file instanceof UploadedFile, 422);
$total += $file->getSize();
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
$result[$key] = ['name' => $name, 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getPathname()), 'mime' => $file->getMimeType()];
}
abort_if($total > max(1, (int) config('candidate_documents.max_total_kb')) * 1024, 422, 'O conjunto de anexos excede o limite técnico de envio.');
ksort($result);
return $result;
}
/** @param array<string,mixed> $definition
* @param array<string,mixed> $files
*/
public function validate(array $definition, array $files): void
{
$documents = $definition['documents'];
$keys = array_column($documents, 'key');
$rules = ['documents' => $keys === [] ? 'present|array|max:0' : 'present|array:'.implode(',', $keys)];
foreach ($documents as $document) {
$rules['documents.'.$document['key']] = ($document['required'] ? 'required' : 'sometimes').'|file|min:0.001|max:'.$document['max_kb'].'|mimes:'.implode(',', $document['formats']).'|extensions:'.implode(',', $document['formats']);
}
Validator::make(['documents' => $files], $rules)->validate();
foreach ($files as $key => $file) {
abort_unless($file instanceof UploadedFile, 422);
$valid = match (strtolower($file->getClientOriginalExtension())) {
'pdf' => str_starts_with((string) file_get_contents($file->getPathname(), false, null, 0, 5), '%PDF-'),
'jpg', 'png' => @getimagesize($file->getPathname()) !== false,
'docx' => $this->wordDocument($file),
default => false,
};
Validator::make(['documents' => [$key => $valid]], ['documents.'.$key => 'accepted'], ['accepted' => 'O conteúdo do documento não corresponde ao formato indicado.'])->validate();
}
}
private function wordDocument(UploadedFile $file): bool
{
if (! class_exists(ZipArchive::class)) {
return false;
}
$zip = new ZipArchive;
if ($zip->open($file->getPathname()) !== true) {
return false;
}
try {
if ($zip->numFiles > 2000) {
return false;
}
$total = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->statIndex($i);
if ($entry === false || str_contains($entry['name'], '..') || str_starts_with($entry['name'], '/') || str_contains($entry['name'], '\\')) {
return false;
}
$total += $entry['size'];
if ($total > 100 * 1024 * 1024 || preg_match('/(?:vbaProject\.bin|\.(?:exe|dll|js|vbs))$/i', $entry['name'])) {
return false;
}
}
foreach (['[Content_Types].xml', 'word/document.xml'] as $part) {
$stat = $zip->statName($part);
if ($stat === false || $stat['size'] > 10 * 1024 * 1024) {
return false;
}
$xml = $zip->getFromName($part);
if ($xml === false || stripos($xml, '<!DOCTYPE') !== false || stripos($xml, '<!ENTITY') !== false) {
return false;
}
$document = new \DOMDocument;
if (! @$document->loadXML($xml, LIBXML_NONET)) {
return false;
}
if ($part === 'word/document.xml' && ($document->documentElement?->localName !== 'document' || $document->documentElement->namespaceURI !== 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')) {
return false;
}
}
return true;
} finally {
$zip->close();
}
}
/** @param array<string,mixed> $document */
public function download(array $document): StreamedResponse
{
$disk = Storage::disk('candidate_documents');
abort_unless($disk->exists($document['path']), 404);
$stream = $disk->readStream($document['path']);
abort_unless(is_resource($stream), 404);
try {
$context = hash_init('sha256');
hash_update_stream($context, $stream);
abort_unless(hash_equals($document['sha256'], hash_final($context)), 409, 'A integridade do documento exige conferência.');
} finally {
fclose($stream);
}
return $disk->download($document['path'], $document['original_name'], ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Robots-Tag' => 'noindex, nofollow']);
}
}