$files * @return array> */ 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 $definition * @param array $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, '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 $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']); } }