Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion bundle/Controller/Resource/Upload.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,23 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;

use function fclose;
use function filesize;
use function fopen;
use function fread;
use function fseek;
use function implode;
use function in_array;
use function is_array;
use function is_file;
use function is_readable;
use function preg_match;
use function strpos;
use function strrpos;
use function strtolower;
use function substr;

use const SEEK_END;

final class Upload extends AbstractController
{
Expand Down Expand Up @@ -88,9 +102,11 @@ public function __invoke(Request $request): Response

$md5 = $this->fileHashFactory->createHash($file->getRealPath());
$fileStruct = FileStruct::fromUploadedFile($file);

$resourceType = $this->isEncryptedPdf($file) ? 'raw' : 'auto';
$resourceStruct = new ResourceStruct(
$fileStruct,
'auto',
$resourceType,
$folder,
$visibility,
$request->request->get('filename'),
Expand All @@ -113,4 +129,47 @@ public function __invoke(Request $request): Response

return new JsonResponse($this->formatResource($resource), $httpCode);
}

private function isEncryptedPdf(UploadedFile $file): bool
{
if (strtolower($file->getClientOriginalExtension()) !== 'pdf') {
return false;
}

$path = (string) $file->getRealPath();
if ($path === '' || !is_file($path) || !is_readable($path)) {
return false;
}

$fp = @fopen($path, 'r');
if ($fp === false) {
return false;
}

$fileSize = filesize($path);

if ($fileSize !== false && $fileSize <= 20480) {
$content = (string) fread($fp, $fileSize);
} else {
$head = (string) fread($fp, 4096);

@fseek($fp, -16384, SEEK_END);
$tail = (string) fread($fp, 16384);

$content = $head . $tail;
}

fclose($fp);

if (strpos($content, '%PDF-') !== 0) {
return false;
}

$eofPos = strrpos($content, '%%EOF');
if ($eofPos !== false) {
$content = substr($content, 0, $eofPos + 5);
}

return (bool) preg_match('/\/(?:Encrypt)\s+(\d+|<<)/m', $content);
}
}
271 changes: 267 additions & 4 deletions tests/bundle/Controller/Resource/UploadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;

use function file_put_contents;
use function json_encode;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;

#[CoversClass(UploadController::class)]
#[CoversClass(AbstractController::class)]
Expand Down Expand Up @@ -78,7 +82,7 @@ public function testUpload(): void
->willReturn('sample_image');

$uploadedFileMock
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('jpg');

Expand Down Expand Up @@ -179,6 +183,265 @@ public function testUpload(): void
);
}

public function testUploadEncryptedPdfIsRaw(): void
{
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_encrypted_pdf_');
file_put_contents($tmpPdfPath, "%PDF-1.7\n1 0 obj\n<< /Encrypt 2 0 R >>\nendobj\n");

$request = new Request();
$request->request->add([
'folder' => 'media/document',
]);

$uploadedFileMock = $this->createMock(UploadedFile::class);

$uploadedFileMock
->expects(self::once())
->method('isFile')
->willReturn(true);

// getRealPath is used for md5 hash + FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(4))
->method('getRealPath')
->willReturn($tmpPdfPath);

$uploadedFileMock
->expects(self::exactly(2))
->method('getClientOriginalName')
->willReturn('protected.pdf');

// getClientOriginalExtension is used for FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('pdf');

$request->files->add([
'file' => $uploadedFileMock,
]);

$this->fileHashFactoryMock
->expects(self::once())
->method('createHash')
->with($tmpPdfPath)
->willReturn('md5hash');

$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);

$resourceStruct = new ResourceStruct(
$fileStruct,
'raw',
Folder::fromPath('media/document'),
'public',
$request->request->get('filename'),
);

$resource = new RemoteResource(
remoteId: 'upload|raw|media/document/protected.pdf',
type: 'raw',
url: 'https://cloudinary.com/test/upload/raw/media/document/protected.pdf',
md5: 'md5hash',
name: 'protected.pdf',
folder: Folder::fromPath('media/document'),
size: 123,
);

$this->providerMock
->expects(self::once())
->method('upload')
->with($resourceStruct)
->willReturn($resource);

$this->providerMock
->expects(self::exactly(0))
->method('buildVariation')
->willReturnCallback(
static fn () => new RemoteResourceVariation(
$resource,
'https://cloudinary.com/test/variation/url',
),
);

$response = $this->controller->__invoke($request);

self::assertInstanceOf(JsonResponse::class, $response);

unlink($tmpPdfPath);
}

public function testUploadPdfWithEncryptInMetadataIsAuto(): void
{
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_unencrypted_pdf_metadata_');
file_put_contents(
$tmpPdfPath,
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
. "/Title (/Encrypt)\n"
. "%%EOF\n",
);

$request = new Request();
$request->request->add([
'folder' => 'media/document',
]);

$uploadedFileMock = $this->createMock(UploadedFile::class);

$uploadedFileMock
->expects(self::once())
->method('isFile')
->willReturn(true);

// getRealPath is used for md5 hash + FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(4))
->method('getRealPath')
->willReturn($tmpPdfPath);

$uploadedFileMock
->expects(self::exactly(2))
->method('getClientOriginalName')
->willReturn('unencrypted.pdf');

// getClientOriginalExtension is used for FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('pdf');

$request->files->add([
'file' => $uploadedFileMock,
]);

$this->fileHashFactoryMock
->expects(self::once())
->method('createHash')
->with($tmpPdfPath)
->willReturn('md5hash');

$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);

$resourceStruct = new ResourceStruct(
$fileStruct,
'auto',
Folder::fromPath('media/document'),
'public',
$request->request->get('filename'),
);

$resource = new RemoteResource(
remoteId: 'upload|auto|media/document/unencrypted.pdf',
type: 'auto',
url: 'https://cloudinary.com/test/upload/auto/media/document/unencrypted.pdf',
md5: 'md5hash',
name: 'unencrypted.pdf',
folder: Folder::fromPath('media/document'),
size: 123,
);

$this->providerMock
->expects(self::once())
->method('upload')
->with($resourceStruct)
->willReturn($resource);

$this->providerMock
->expects(self::exactly(0))
->method('buildVariation');

$response = $this->controller->__invoke($request);

self::assertInstanceOf(JsonResponse::class, $response);

unlink($tmpPdfPath);
}

public function testUploadPdfWithEncryptInTrailingCommentAfterEofIsAuto(): void
{
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_unencrypted_pdf_comment_');
file_put_contents(
$tmpPdfPath,
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
. "%%EOF\n"
. "% /Encrypt 2 0 R\n",
);

$request = new Request();
$request->request->add([
'folder' => 'media/document',
]);

$uploadedFileMock = $this->createMock(UploadedFile::class);

$uploadedFileMock
->expects(self::once())
->method('isFile')
->willReturn(true);

// getRealPath is used for md5 hash + FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(4))
->method('getRealPath')
->willReturn($tmpPdfPath);

$uploadedFileMock
->expects(self::exactly(2))
->method('getClientOriginalName')
->willReturn('unencrypted.pdf');

// getClientOriginalExtension is used for FileStruct + encryption detector
$uploadedFileMock
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('pdf');

$request->files->add([
'file' => $uploadedFileMock,
]);

$this->fileHashFactoryMock
->expects(self::once())
->method('createHash')
->with($tmpPdfPath)
->willReturn('md5hash');

$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);

$resourceStruct = new ResourceStruct(
$fileStruct,
'auto',
Folder::fromPath('media/document'),
'public',
$request->request->get('filename'),
);

$resource = new RemoteResource(
remoteId: 'upload|auto|media/document/unencrypted.pdf',
type: 'auto',
url: 'https://cloudinary.com/test/upload/auto/media/document/unencrypted.pdf',
md5: 'md5hash',
name: 'unencrypted.pdf',
folder: Folder::fromPath('media/document'),
size: 123,
);

$this->providerMock
->expects(self::once())
->method('upload')
->with($resourceStruct)
->willReturn($resource);

$this->providerMock
->expects(self::exactly(0))
->method('buildVariation');

$response = $this->controller->__invoke($request);

self::assertInstanceOf(JsonResponse::class, $response);

unlink($tmpPdfPath);
}

public function testUploadProtectedWithContext(): void
{
$uploadContext = [
Expand Down Expand Up @@ -211,7 +474,7 @@ public function testUploadProtectedWithContext(): void
->willReturn('sample_image');

$uploadedFileMock
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('jpg');

Expand Down Expand Up @@ -396,7 +659,7 @@ public function testUploadExistingFile(): void
->willReturn('sample_image');

$uploadedFileMock
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('jpg');

Expand Down Expand Up @@ -555,7 +818,7 @@ public function testUploadExistingFileName(): void
->willReturn('sample_image');

$uploadedFileMock
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('getClientOriginalExtension')
->willReturn('jpg');

Expand Down
Loading