124 lines
5.5 KiB
PHP
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']);
|
|
}
|
|
}
|