Skip to content

Commit 60444cf

Browse files
authored
Merge pull request #119 from netgen/SPLAT-4180-password-protected-pdf
Handle Encrypted PDF files
2 parents ac6d16b + a4021da commit 60444cf

2 files changed

Lines changed: 327 additions & 5 deletions

File tree

bundle/Controller/Resource/Upload.php

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,23 @@
1818
use Symfony\Component\HttpFoundation\Response;
1919
use Symfony\Contracts\Translation\TranslatorInterface;
2020

21+
use function fclose;
22+
use function filesize;
23+
use function fopen;
24+
use function fread;
25+
use function fseek;
2126
use function implode;
2227
use function in_array;
2328
use function is_array;
29+
use function is_file;
30+
use function is_readable;
31+
use function preg_match;
32+
use function strpos;
33+
use function strrpos;
34+
use function strtolower;
35+
use function substr;
36+
37+
use const SEEK_END;
2438

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

89103
$md5 = $this->fileHashFactory->createHash($file->getRealPath());
90104
$fileStruct = FileStruct::fromUploadedFile($file);
105+
106+
$resourceType = $this->isEncryptedPdf($file) ? 'raw' : 'auto';
91107
$resourceStruct = new ResourceStruct(
92108
$fileStruct,
93-
'auto',
109+
$resourceType,
94110
$folder,
95111
$visibility,
96112
$request->request->get('filename'),
@@ -113,4 +129,47 @@ public function __invoke(Request $request): Response
113129

114130
return new JsonResponse($this->formatResource($resource), $httpCode);
115131
}
132+
133+
private function isEncryptedPdf(UploadedFile $file): bool
134+
{
135+
if (strtolower($file->getClientOriginalExtension()) !== 'pdf') {
136+
return false;
137+
}
138+
139+
$path = (string) $file->getRealPath();
140+
if ($path === '' || !is_file($path) || !is_readable($path)) {
141+
return false;
142+
}
143+
144+
$fp = @fopen($path, 'r');
145+
if ($fp === false) {
146+
return false;
147+
}
148+
149+
$fileSize = filesize($path);
150+
151+
if ($fileSize !== false && $fileSize <= 20480) {
152+
$content = (string) fread($fp, $fileSize);
153+
} else {
154+
$head = (string) fread($fp, 4096);
155+
156+
@fseek($fp, -16384, SEEK_END);
157+
$tail = (string) fread($fp, 16384);
158+
159+
$content = $head . $tail;
160+
}
161+
162+
fclose($fp);
163+
164+
if (strpos($content, '%PDF-') !== 0) {
165+
return false;
166+
}
167+
168+
$eofPos = strrpos($content, '%%EOF');
169+
if ($eofPos !== false) {
170+
$content = substr($content, 0, $eofPos + 5);
171+
}
172+
173+
return (bool) preg_match('/\/(?:Encrypt)\s+(\d+|<<)/m', $content);
174+
}
116175
}

tests/bundle/Controller/Resource/UploadTest.php

Lines changed: 267 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
use Symfony\Component\HttpFoundation\Response;
2828
use Symfony\Contracts\Translation\TranslatorInterface;
2929

30+
use function file_put_contents;
3031
use function json_encode;
32+
use function sys_get_temp_dir;
33+
use function tempnam;
34+
use function unlink;
3135

3236
#[CoversClass(UploadController::class)]
3337
#[CoversClass(AbstractController::class)]
@@ -78,7 +82,7 @@ public function testUpload(): void
7882
->willReturn('sample_image');
7983

8084
$uploadedFileMock
81-
->expects(self::exactly(2))
85+
->expects(self::exactly(3))
8286
->method('getClientOriginalExtension')
8387
->willReturn('jpg');
8488

@@ -179,6 +183,265 @@ public function testUpload(): void
179183
);
180184
}
181185

186+
public function testUploadEncryptedPdfIsRaw(): void
187+
{
188+
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_encrypted_pdf_');
189+
file_put_contents($tmpPdfPath, "%PDF-1.7\n1 0 obj\n<< /Encrypt 2 0 R >>\nendobj\n");
190+
191+
$request = new Request();
192+
$request->request->add([
193+
'folder' => 'media/document',
194+
]);
195+
196+
$uploadedFileMock = $this->createMock(UploadedFile::class);
197+
198+
$uploadedFileMock
199+
->expects(self::once())
200+
->method('isFile')
201+
->willReturn(true);
202+
203+
// getRealPath is used for md5 hash + FileStruct + encryption detector
204+
$uploadedFileMock
205+
->expects(self::exactly(4))
206+
->method('getRealPath')
207+
->willReturn($tmpPdfPath);
208+
209+
$uploadedFileMock
210+
->expects(self::exactly(2))
211+
->method('getClientOriginalName')
212+
->willReturn('protected.pdf');
213+
214+
// getClientOriginalExtension is used for FileStruct + encryption detector
215+
$uploadedFileMock
216+
->expects(self::exactly(3))
217+
->method('getClientOriginalExtension')
218+
->willReturn('pdf');
219+
220+
$request->files->add([
221+
'file' => $uploadedFileMock,
222+
]);
223+
224+
$this->fileHashFactoryMock
225+
->expects(self::once())
226+
->method('createHash')
227+
->with($tmpPdfPath)
228+
->willReturn('md5hash');
229+
230+
$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);
231+
232+
$resourceStruct = new ResourceStruct(
233+
$fileStruct,
234+
'raw',
235+
Folder::fromPath('media/document'),
236+
'public',
237+
$request->request->get('filename'),
238+
);
239+
240+
$resource = new RemoteResource(
241+
remoteId: 'upload|raw|media/document/protected.pdf',
242+
type: 'raw',
243+
url: 'https://cloudinary.com/test/upload/raw/media/document/protected.pdf',
244+
md5: 'md5hash',
245+
name: 'protected.pdf',
246+
folder: Folder::fromPath('media/document'),
247+
size: 123,
248+
);
249+
250+
$this->providerMock
251+
->expects(self::once())
252+
->method('upload')
253+
->with($resourceStruct)
254+
->willReturn($resource);
255+
256+
$this->providerMock
257+
->expects(self::exactly(0))
258+
->method('buildVariation')
259+
->willReturnCallback(
260+
static fn () => new RemoteResourceVariation(
261+
$resource,
262+
'https://cloudinary.com/test/variation/url',
263+
),
264+
);
265+
266+
$response = $this->controller->__invoke($request);
267+
268+
self::assertInstanceOf(JsonResponse::class, $response);
269+
270+
unlink($tmpPdfPath);
271+
}
272+
273+
public function testUploadPdfWithEncryptInMetadataIsAuto(): void
274+
{
275+
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_unencrypted_pdf_metadata_');
276+
file_put_contents(
277+
$tmpPdfPath,
278+
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
279+
. "/Title (/Encrypt)\n"
280+
. "%%EOF\n",
281+
);
282+
283+
$request = new Request();
284+
$request->request->add([
285+
'folder' => 'media/document',
286+
]);
287+
288+
$uploadedFileMock = $this->createMock(UploadedFile::class);
289+
290+
$uploadedFileMock
291+
->expects(self::once())
292+
->method('isFile')
293+
->willReturn(true);
294+
295+
// getRealPath is used for md5 hash + FileStruct + encryption detector
296+
$uploadedFileMock
297+
->expects(self::exactly(4))
298+
->method('getRealPath')
299+
->willReturn($tmpPdfPath);
300+
301+
$uploadedFileMock
302+
->expects(self::exactly(2))
303+
->method('getClientOriginalName')
304+
->willReturn('unencrypted.pdf');
305+
306+
// getClientOriginalExtension is used for FileStruct + encryption detector
307+
$uploadedFileMock
308+
->expects(self::exactly(3))
309+
->method('getClientOriginalExtension')
310+
->willReturn('pdf');
311+
312+
$request->files->add([
313+
'file' => $uploadedFileMock,
314+
]);
315+
316+
$this->fileHashFactoryMock
317+
->expects(self::once())
318+
->method('createHash')
319+
->with($tmpPdfPath)
320+
->willReturn('md5hash');
321+
322+
$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);
323+
324+
$resourceStruct = new ResourceStruct(
325+
$fileStruct,
326+
'auto',
327+
Folder::fromPath('media/document'),
328+
'public',
329+
$request->request->get('filename'),
330+
);
331+
332+
$resource = new RemoteResource(
333+
remoteId: 'upload|auto|media/document/unencrypted.pdf',
334+
type: 'auto',
335+
url: 'https://cloudinary.com/test/upload/auto/media/document/unencrypted.pdf',
336+
md5: 'md5hash',
337+
name: 'unencrypted.pdf',
338+
folder: Folder::fromPath('media/document'),
339+
size: 123,
340+
);
341+
342+
$this->providerMock
343+
->expects(self::once())
344+
->method('upload')
345+
->with($resourceStruct)
346+
->willReturn($resource);
347+
348+
$this->providerMock
349+
->expects(self::exactly(0))
350+
->method('buildVariation');
351+
352+
$response = $this->controller->__invoke($request);
353+
354+
self::assertInstanceOf(JsonResponse::class, $response);
355+
356+
unlink($tmpPdfPath);
357+
}
358+
359+
public function testUploadPdfWithEncryptInTrailingCommentAfterEofIsAuto(): void
360+
{
361+
$tmpPdfPath = (string) tempnam(sys_get_temp_dir(), 'ngrm_unencrypted_pdf_comment_');
362+
file_put_contents(
363+
$tmpPdfPath,
364+
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
365+
. "%%EOF\n"
366+
. "% /Encrypt 2 0 R\n",
367+
);
368+
369+
$request = new Request();
370+
$request->request->add([
371+
'folder' => 'media/document',
372+
]);
373+
374+
$uploadedFileMock = $this->createMock(UploadedFile::class);
375+
376+
$uploadedFileMock
377+
->expects(self::once())
378+
->method('isFile')
379+
->willReturn(true);
380+
381+
// getRealPath is used for md5 hash + FileStruct + encryption detector
382+
$uploadedFileMock
383+
->expects(self::exactly(4))
384+
->method('getRealPath')
385+
->willReturn($tmpPdfPath);
386+
387+
$uploadedFileMock
388+
->expects(self::exactly(2))
389+
->method('getClientOriginalName')
390+
->willReturn('unencrypted.pdf');
391+
392+
// getClientOriginalExtension is used for FileStruct + encryption detector
393+
$uploadedFileMock
394+
->expects(self::exactly(3))
395+
->method('getClientOriginalExtension')
396+
->willReturn('pdf');
397+
398+
$request->files->add([
399+
'file' => $uploadedFileMock,
400+
]);
401+
402+
$this->fileHashFactoryMock
403+
->expects(self::once())
404+
->method('createHash')
405+
->with($tmpPdfPath)
406+
->willReturn('md5hash');
407+
408+
$fileStruct = FileStruct::fromUploadedFile($uploadedFileMock);
409+
410+
$resourceStruct = new ResourceStruct(
411+
$fileStruct,
412+
'auto',
413+
Folder::fromPath('media/document'),
414+
'public',
415+
$request->request->get('filename'),
416+
);
417+
418+
$resource = new RemoteResource(
419+
remoteId: 'upload|auto|media/document/unencrypted.pdf',
420+
type: 'auto',
421+
url: 'https://cloudinary.com/test/upload/auto/media/document/unencrypted.pdf',
422+
md5: 'md5hash',
423+
name: 'unencrypted.pdf',
424+
folder: Folder::fromPath('media/document'),
425+
size: 123,
426+
);
427+
428+
$this->providerMock
429+
->expects(self::once())
430+
->method('upload')
431+
->with($resourceStruct)
432+
->willReturn($resource);
433+
434+
$this->providerMock
435+
->expects(self::exactly(0))
436+
->method('buildVariation');
437+
438+
$response = $this->controller->__invoke($request);
439+
440+
self::assertInstanceOf(JsonResponse::class, $response);
441+
442+
unlink($tmpPdfPath);
443+
}
444+
182445
public function testUploadProtectedWithContext(): void
183446
{
184447
$uploadContext = [
@@ -211,7 +474,7 @@ public function testUploadProtectedWithContext(): void
211474
->willReturn('sample_image');
212475

213476
$uploadedFileMock
214-
->expects(self::exactly(2))
477+
->expects(self::exactly(3))
215478
->method('getClientOriginalExtension')
216479
->willReturn('jpg');
217480

@@ -396,7 +659,7 @@ public function testUploadExistingFile(): void
396659
->willReturn('sample_image');
397660

398661
$uploadedFileMock
399-
->expects(self::exactly(2))
662+
->expects(self::exactly(3))
400663
->method('getClientOriginalExtension')
401664
->willReturn('jpg');
402665

@@ -555,7 +818,7 @@ public function testUploadExistingFileName(): void
555818
->willReturn('sample_image');
556819

557820
$uploadedFileMock
558-
->expects(self::exactly(2))
821+
->expects(self::exactly(3))
559822
->method('getClientOriginalExtension')
560823
->willReturn('jpg');
561824

0 commit comments

Comments
 (0)