• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nepada / file-upload-control / 6449927556

08 Oct 2023 08:55PM UTC coverage: 90.363% (+0.05%) from 90.309%
6449927556

push

github

xificurk
Drop useless dev dependency

647 of 716 relevant lines covered (90.36%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.67
/src/FileUploadControl/FileUploadControl.php
1
<?php
2
declare(strict_types = 1);
3

4
namespace Nepada\FileUploadControl;
5

6
use Nepada\FileUploadControl\Responses\ListResponse;
7
use Nepada\FileUploadControl\Responses\Response;
8
use Nepada\FileUploadControl\Responses\UploadErrorResponse;
9
use Nepada\FileUploadControl\Responses\UploadSuccessResponse;
10
use Nepada\FileUploadControl\Storage\ContentRange;
11
use Nepada\FileUploadControl\Storage\FileUploadChunk;
12
use Nepada\FileUploadControl\Storage\FileUploadId;
13
use Nepada\FileUploadControl\Storage\FileUploadItem;
14
use Nepada\FileUploadControl\Storage\FileUploadNotFoundException;
15
use Nepada\FileUploadControl\Storage\Storage;
16
use Nepada\FileUploadControl\Storage\StorageDoesNotExistException;
17
use Nepada\FileUploadControl\Storage\StorageManager;
18
use Nepada\FileUploadControl\Storage\UnableToSaveFileUploadException;
19
use Nepada\FileUploadControl\Storage\UploadNamespace;
20
use Nepada\FileUploadControl\Thumbnail\NullThumbnailProvider;
21
use Nepada\FileUploadControl\Thumbnail\ThumbnailProvider;
22
use Nepada\FileUploadControl\Validation\UploadValidation;
23
use Nette;
24
use Nette\Bridges\ApplicationLatte\Template;
25
use Nette\Forms\Form;
26
use Nette\Http\FileUpload;
27
use Nette\Utils\Html;
28
use Nette\Utils\Strings;
29
use Nextras\FormComponents\Fragments\UIControl\BaseControl;
30

31
class FileUploadControl extends BaseControl
32
{
33

34
    use UploadValidation {
35
        UploadValidation::addRule as private _addRule;
36
    }
37

38
    public const DEFAULT_TEMPLATE_FILE = __DIR__ . '/templates/bootstrap4.latte';
39

40
    private string $templateFile = self::DEFAULT_TEMPLATE_FILE;
41

42
    private StorageManager $storageManager;
43

44
    private ThumbnailProvider $thumbnailProvider;
45

46
    private bool $httpDataLoaded = false;
47

48
    public function __construct(StorageManager $storageManager, string|Html|null $caption = null)
1✔
49
    {
50
        parent::__construct($caption);
1✔
51
        $this->storageManager = $storageManager;
1✔
52
        $this->thumbnailProvider = new NullThumbnailProvider();
1✔
53
        $this->control = Html::el();
1✔
54
        $this->setOption('type', 'file-upload');
1✔
55
        $this->addComponent(new Nette\Forms\Controls\UploadControl($caption, true), 'upload');
1✔
56
        $this->addComponent(new Nette\Forms\Controls\HiddenField(), 'namespace');
1✔
57
        $this->initializeValidation($this);
1✔
58
    }
1✔
59

60
    public function setThumbnailProvider(ThumbnailProvider $thumbnailProvider): void
1✔
61
    {
62
        $this->thumbnailProvider = $thumbnailProvider;
1✔
63
    }
1✔
64

65
    public function setTemplateFile(string $templateFile): void
1✔
66
    {
67
        $this->templateFile = $templateFile;
1✔
68
    }
1✔
69

70
    /**
71
     * @throws Nette\Application\BadRequestException
72
     */
73
    public function loadHttpData(): void
74
    {
75
        $this->getNamespaceControl()->loadHttpData();
1✔
76
        try {
77
            $storage = $this->getStorage();
1✔
78
        } catch (StorageDoesNotExistException $exception) {
×
79
            // refresh namespace
80
            $this->setUploadNamespace($this->storageManager->createNewNamespace());
×
81
            try {
82
                $storage = $this->getStorage();
×
83
            } catch (StorageDoesNotExistException $exception) {
×
84
                throw new \LogicException($exception->getMessage(), 0, $exception);
×
85
            }
86
        }
87

88
        if ($this->httpDataLoaded || $this->isDisabled()) {
1✔
89
            return;
×
90
        }
91

92
        $this->httpDataLoaded = true;
1✔
93

94
        $fileUploadChunks = $this->getFileUploadChunks();
1✔
95
        if (count($fileUploadChunks) === 0) {
1✔
96
            return;
1✔
97
        }
98

99
        $uploadFailed = false;
1✔
100
        foreach ($fileUploadChunks as $fileUploadChunk) {
1✔
101
            try {
102
                $storage->save($fileUploadChunk);
1✔
103
            } catch (UnableToSaveFileUploadException $exception) {
×
104
                $uploadFailed = true;
×
105
            }
106
        }
107
        if ($uploadFailed) {
1✔
108
            $this->addError('Upload error');
×
109
        }
110
    }
1✔
111

112
    /**
113
     * @return FileUpload[]
114
     */
115
    public function getValue(): array
116
    {
117
        if ($this->isDisabled()) {
1✔
118
            return [];
1✔
119
        }
120

121
        try {
122
            return array_map(fn (FileUploadItem $fileUploadItem): FileUpload => $fileUploadItem->getFileUpload(), $this->getStorage()->list());
1✔
123
        } catch (StorageDoesNotExistException $exception) {
×
124
            throw new \LogicException($exception->getMessage(), 0, $exception);
×
125
        }
126
    }
127

128
    /**
129
     * @return $this
130
     * @internal
131
     */
132
    public function setValue(mixed $value): static
1✔
133
    {
134
        return $this;
1✔
135
    }
136

137
    public function isDisabled(): bool
138
    {
139
        return $this->getUploadControl()->isDisabled();
1✔
140
    }
141

142
    /**
143
     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
144
     * @param bool $value
145
     * @return $this
146
     * @throws Nette\Application\BadRequestException
147
     */
148
    public function setDisabled($value = true): static
1✔
149
    {
150
        $this->getUploadControl()->setDisabled($value);
1✔
151
        if (! $value) {
1✔
152
            return $this;
×
153
        }
154

155
        $form = $this->getForm(false);
1✔
156
        if ($form !== null && $form->isAnchored() && (bool) $form->isSubmitted()) {
1✔
157
            $this->loadHttpData();
×
158
        }
159

160
        return $this;
1✔
161
    }
162

163
    public function getControlPart(?string $key = null): ?Html
1✔
164
    {
165
        if ($key === 'namespace') {
1✔
166
            return $this->getNamespaceControl()->getControl();
1✔
167
        }
168

169
        if ($key === 'upload') {
1✔
170
            $control = $this->getUploadControl()->getControl();
1✔
171
            assert($control instanceof Html);
1✔
172
            $control->{'data-nette-rules'} = Nette\Forms\Helpers::exportRules($this->getRules());
1✔
173
            return $control;
1✔
174
        }
175

176
        return $this->getControl();
×
177
    }
178

179
    public function getControl(): Html
180
    {
181
        $this->setOption('rendered', true);
1✔
182
        $control = clone $this->control;
1✔
183

184
        $template = $this->getTemplate();
1✔
185
        assert($template instanceof Template);
1✔
186

187
        try {
188
            $storage = $this->getStorage();
1✔
189
        } catch (StorageDoesNotExistException $exception) {
×
190
            throw new \LogicException($exception->getMessage(), 0, $exception);
×
191
        }
192
        $fileUploadItems = $storage->list();
1✔
193
        $uniqueFilenames = array_map(fn (FileUploadItem $fileUploadItem): string => $fileUploadItem->getFileUpload()->getUntrustedName(), $fileUploadItems);
1✔
194
        $completedFiles = array_map(
1✔
195
            fn (FileUploadItem $fileUploadItem): Response => $this->createUploadSuccessResponse($fileUploadItem),
1✔
196
            array_filter($fileUploadItems, fn (FileUploadItem $fileUploadItem): bool => $fileUploadItem->getFileUpload()->isOk()),
1✔
197
        );
198

199
        $template->uploadUrl = $this->link('upload!', ['namespace' => $this->getUploadNamespace()->toString()]);
1✔
200
        $template->uniqueFilenames = $uniqueFilenames;
1✔
201
        $template->completedFiles = $completedFiles;
1✔
202

203
        $controlHtml = Nette\Utils\Helpers::capture(function () use ($template): void {
1✔
204
            $template->render($this->templateFile);
1✔
205
        });
1✔
206
        return $control->addHtml($controlHtml);
1✔
207
    }
208

209
    public function getLabelPrototype(): Html
210
    {
211
        return $this->getUploadControl()->getLabelPrototype();
1✔
212
    }
213

214
    /**
215
     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
216
     * @param string|Html|null $caption
217
     */
218
    public function getLabel($caption = null): Html
1✔
219
    {
220
        $label = $this->getUploadControl()->getLabel($caption);
1✔
221
        assert($label instanceof Html);
1✔
222
        return $label;
1✔
223
    }
224

225
    /**
226
     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
227
     * @param callable|string $validator
228
     * @param string|Html|null $errorMessage
229
     * @return $this
230
     */
231
    public function addRule($validator, $errorMessage = null, mixed $arg = null): static
1✔
232
    {
233
        if ($validator === Form::IMAGE) {
1✔
234
            $this->getUploadControl()->setHtmlAttribute('accept', implode(',', FileUpload::IMAGE_MIME_TYPES));
1✔
235
        } elseif ($validator === Form::MIME_TYPE) {
1✔
236
            $mimeTypes = is_array($arg) ? $arg : ($arg === null ? [] : [$arg]);
1✔
237
            $this->getUploadControl()->setHtmlAttribute('accept', implode(',', $mimeTypes));
1✔
238
        }
239

240
        return $this->_addRule($validator, $errorMessage, $arg);
1✔
241
    }
242

243
    /**
244
     * @throws Nette\Application\BadRequestException
245
     */
246
    public function handleUpload(string $namespace): void
1✔
247
    {
248
        $uploadNamespace = $this->parseUploadNamespace($namespace);
1✔
249
        $this->setUploadNamespace($uploadNamespace);
1✔
250

251
        $fileUploadChunks = $this->getFileUploadChunks();
1✔
252
        $responses = [];
1✔
253
        foreach ($fileUploadChunks as $fileUploadChunk) {
1✔
254
            if ($this->isDisabled()) {
1✔
255
                $responses[] = $this->createUploadErrorResponse($fileUploadChunk, $this->translate('Upload disabled'));
1✔
256
                continue;
1✔
257
            }
258

259
            $fakeFileUpload = new FileUpload([
1✔
260
                'name' => $fileUploadChunk->getFileUpload()->getUntrustedName(),
1✔
261
                'size' => $fileUploadChunk->getContentRange()->getSize(),
1✔
262
                'tmp_name' => $fileUploadChunk->getFileUpload()->getTemporaryFile(),
1✔
263
                'error' => UPLOAD_ERR_OK,
1✔
264
            ]);
265
            $this->fakeUploadControl->setNewFileUpload($fakeFileUpload);
1✔
266
            $this->validate();
1✔
267
            $error = $this->getError();
1✔
268
            if ($error !== null) {
1✔
269
                $responses[] = $this->createUploadErrorResponse($fileUploadChunk, $error);
1✔
270
                continue;
1✔
271
            }
272

273
            try {
274
                $fileUploadItem = $this->getStorage()->save($fileUploadChunk);
1✔
275
                $responses[] = $this->createUploadSuccessResponse($fileUploadItem);
1✔
276
            } catch (StorageDoesNotExistException | UnableToSaveFileUploadException $exception) {
×
277
                $responses[] = $this->createUploadErrorResponse($fileUploadChunk, $this->translate('Upload error'));
×
278
            }
279
        }
280

281
        $this->sendJson(new ListResponse(...$responses));
1✔
282
    }
283

284
    /**
285
     * @throws Nette\Application\BadRequestException
286
     */
287
    public function handleDelete(string $namespace, string $id): void
288
    {
289
        $fileUploadId = $this->parseFileUploadId($id);
×
290
        $uploadNamespace = $this->parseUploadNamespace($namespace);
×
291
        $this->setUploadNamespace($uploadNamespace);
×
292

293
        try {
294
            $this->getStorage()->delete($fileUploadId);
×
295
        } catch (StorageDoesNotExistException $exception) {
×
296
            // noop
297
        }
298
        $this->sendJson('');
×
299
    }
300

301
    /**
302
     * @throws Nette\Application\BadRequestException
303
     */
304
    public function handleDownload(string $namespace, string $id): void
1✔
305
    {
306
        $fileUploadId = $this->parseFileUploadId($id);
1✔
307
        $uploadNamespace = $this->parseUploadNamespace($namespace);
1✔
308
        $this->setUploadNamespace($uploadNamespace);
1✔
309

310
        try {
311
            $fileUploadItem = $this->getStorage()->load($fileUploadId);
1✔
312
        } catch (StorageDoesNotExistException | FileUploadNotFoundException $exception) {
×
313
            throw new Nette\Application\BadRequestException('File upload not found.', 0, $exception);
×
314
        }
315

316
        $fileUpload = $fileUploadItem->getFileUpload();
1✔
317
        $response = new Nette\Application\Responses\FileResponse(
1✔
318
            $fileUpload->getTemporaryFile(),
1✔
319
            $fileUpload->getUntrustedName(),
1✔
320
        );
321
        $this->getPresenter()->sendResponse($response);
1✔
322
    }
323

324
    /**
325
     * @throws Nette\Application\BadRequestException
326
     */
327
    public function handleThumbnail(string $namespace, string $id): void
328
    {
329
        $fileUploadId = $this->parseFileUploadId($id);
×
330
        $uploadNamespace = $this->parseUploadNamespace($namespace);
×
331
        $this->setUploadNamespace($uploadNamespace);
×
332

333
        try {
334
            $fileUpload = $this->getStorage()->load($fileUploadId)->getFileUpload();
×
335
        } catch (StorageDoesNotExistException | FileUploadNotFoundException $exception) {
×
336
            throw new Nette\Application\BadRequestException('Source file for thumbnail not found.', 0, $exception);
×
337
        }
338

339
        if (! $this->thumbnailProvider->isSupported($fileUpload)) {
×
340
            throw new Nette\Application\BadRequestException('Thumbnail could not be generated');
×
341
        }
342

343
        $response = $this->thumbnailProvider->createThumbnail($fileUpload);
×
344
        $this->getPresenter()->sendResponse($response);
×
345
    }
346

347
    protected function createTemplate(): Template
348
    {
349
        $template = parent::createTemplate();
1✔
350
        assert($template instanceof Template);
1✔
351

352
        $translator = $this->getTranslator();
1✔
353
        if ($translator !== null) {
1✔
354
            $template->setTranslator($translator);
1✔
355
        }
356

357
        $template->getLatte()->addFilter('json', fn ($data): string => Nette\Utils\Json::encode($data));
1✔
358

359
        return $template;
1✔
360
    }
361

362
    protected function getHttpRequest(): Nette\Http\IRequest
363
    {
364
        return $this->getPresenter()->getHttpRequest();
1✔
365
    }
366

367
    /**
368
     * Sends back JSON response.
369
     * Sets the right content type based on the support on the other end.
370
     * https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#wiki-content-type-negotiation
371
     */
372
    protected function sendJson(mixed $data): void
1✔
373
    {
374
        $contentType = Strings::contains((string) $this->getHttpRequest()->getHeader('accept'), 'application/json') ? 'application/json' : 'text/plain';
1✔
375
        $response = new Nette\Application\Responses\JsonResponse($data, $contentType);
1✔
376
        $this->getPresenter()->sendResponse($response);
1✔
377
    }
378

379
    protected function getUploadControl(): Nette\Forms\Controls\UploadControl
380
    {
381
        return $this->getComponent('upload');
1✔
382
    }
383

384
    protected function getNamespaceControl(): Nette\Forms\Controls\HiddenField
385
    {
386
        return $this->getComponent('namespace');
1✔
387
    }
388

389
    protected function getThumbnailProvider(): ThumbnailProvider
390
    {
391
        return $this->thumbnailProvider;
×
392
    }
393

394
    protected function getUploadNamespace(): UploadNamespace
395
    {
396
        $nameSpaceValue = (string) $this->getNamespaceControl()->getValue();
1✔
397
        if (UploadNamespace::isValid($nameSpaceValue)) {
1✔
398
            return UploadNamespace::fromString($nameSpaceValue);
1✔
399
        }
400

401
        $namespace = $this->storageManager->createNewNamespace();
×
402
        $this->setUploadNamespace($namespace);
×
403
        return $namespace;
×
404
    }
405

406
    protected function setUploadNamespace(UploadNamespace $namespace): void
1✔
407
    {
408
        $this->getNamespaceControl()->setValue($namespace->toString());
1✔
409
    }
1✔
410

411
    /**
412
     * @throws StorageDoesNotExistException
413
     */
414
    protected function getStorage(): Storage
415
    {
416
        return $this->storageManager->getStorage($this->getUploadNamespace());
1✔
417
    }
418

419
    protected function createUploadSuccessResponse(FileUploadItem $fileUploadItem): Response
1✔
420
    {
421
        $fileUpload = $fileUploadItem->getFileUpload();
1✔
422
        $idValue = $fileUploadItem->getId()->toString();
1✔
423
        $namespaceValue = $this->getUploadNamespace()->toString();
1✔
424
        return new UploadSuccessResponse(
1✔
425
            $fileUpload->getUntrustedName(),
1✔
426
            $fileUpload->getSize(),
1✔
427
            $fileUpload->getContentType(),
1✔
428
            $this->link('download!', ['namespace' => $namespaceValue, 'id' => $idValue]),
1✔
429
            $this->link('delete!', ['namespace' => $namespaceValue, 'id' => $idValue]),
1✔
430
            $this->thumbnailProvider->isSupported($fileUpload) ? $this->link('thumbnail!', ['namespace' => $namespaceValue, 'id' => $idValue]) : null,
1✔
431
        );
432
    }
433

434
    protected function createUploadErrorResponse(FileUploadChunk $fileUploadChunk, string $error): Response
1✔
435
    {
436
        return new UploadErrorResponse(
1✔
437
            $fileUploadChunk->getFileUpload()->getUntrustedName(),
1✔
438
            $fileUploadChunk->getContentRange()->getSize(),
1✔
439
            $error,
440
        );
441
    }
442

443
    /**
444
     * @throws Nette\Application\BadRequestException
445
     */
446
    protected function parseUploadNamespace(string $value): UploadNamespace
1✔
447
    {
448
        if (! UploadNamespace::isValid($value)) {
1✔
449
            throw new Nette\Application\BadRequestException('Invalid namespace value', 400);
×
450
        }
451
        return UploadNamespace::fromString($value);
1✔
452
    }
453

454
    /**
455
     * @throws Nette\Application\BadRequestException
456
     */
457
    protected function parseFileUploadId(string $value): FileUploadId
1✔
458
    {
459
        if (! FileUploadId::isValid($value)) {
1✔
460
            throw new Nette\Application\BadRequestException('Invalid file upload id value', 400);
×
461
        }
462
        return FileUploadId::fromString($value);
1✔
463
    }
464

465
    /**
466
     * @return FileUploadChunk[]
467
     * @throws Nette\Application\BadRequestException
468
     */
469
    protected function getFileUploadChunks(): array
470
    {
471
        $httpRequest = $this->getHttpRequest();
1✔
472
        /** @var array<int, FileUpload> $files */
473
        $files = Nette\Forms\Helpers::extractHttpData($httpRequest->getFiles(), $this->getUploadControl()->getHtmlName() . '[]', Form::DATA_FILE);
1✔
474
        if (count($files) === 0) {
1✔
475
            return [];
1✔
476
        }
477

478
        $contentRangeHeaderValue = $httpRequest->getHeader('content-range');
1✔
479
        if ($contentRangeHeaderValue !== null) {
1✔
480
            if (count($files) > 1) {
1✔
481
                throw new Nette\Application\BadRequestException('Chunk upload does not support multi-file upload', 400);
×
482
            }
483
            try {
484
                $contentRange = ContentRange::fromHttpHeaderValue($contentRangeHeaderValue);
1✔
485
                $fileUploadChunk = FileUploadChunk::partialUpload(reset($files), $contentRange);
1✔
486
                return [$fileUploadChunk];
1✔
487
            } catch (\Throwable $exception) {
×
488
                throw new Nette\Application\BadRequestException('Invalid content-range header value', 400, $exception);
×
489
            }
490
        }
491

492
        /** @var FileUploadChunk[] $fileUploadChunks */
493
        $fileUploadChunks = [];
1✔
494
        foreach ($files as $file) {
1✔
495
            $fileUploadChunks[] = FileUploadChunk::completeUpload($file);
1✔
496
        }
497

498
        return $fileUploadChunks;
1✔
499
    }
500

501
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc