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

nepada / file-upload-control / 4581408359

pending completion
4581408359

Pull #103

github

GitHub
Merge ed661242b into c8546f895
Pull Request #103: Update nepada/coding-standard requirement from 7.6.0 to 7.7.0

641 of 710 relevant lines covered (90.28%)

0.9 hits per line

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

78.47
/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
    /**
49
     * @param StorageManager $storageManager
50
     * @param string|Html|null $caption
51
     */
52
    public function __construct(StorageManager $storageManager, $caption = null)
1✔
53
    {
54
        parent::__construct($caption);
1✔
55
        $this->storageManager = $storageManager;
1✔
56
        $this->thumbnailProvider = new NullThumbnailProvider();
1✔
57
        $this->control = Html::el();
1✔
58
        $this->setOption('type', 'file-upload');
1✔
59
        $this->addComponent(new Nette\Forms\Controls\UploadControl($caption, true), 'upload');
1✔
60
        $this->addComponent(new Nette\Forms\Controls\HiddenField(), 'namespace');
1✔
61
        $this->initializeValidation($this);
1✔
62
    }
1✔
63

64
    public function setThumbnailProvider(ThumbnailProvider $thumbnailProvider): void
1✔
65
    {
66
        $this->thumbnailProvider = $thumbnailProvider;
1✔
67
    }
1✔
68

69
    public function setTemplateFile(string $templateFile): void
1✔
70
    {
71
        $this->templateFile = $templateFile;
1✔
72
    }
1✔
73

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

92
        if ($this->httpDataLoaded || $this->isDisabled()) {
1✔
93
            return;
×
94
        }
95

96
        $this->httpDataLoaded = true;
1✔
97

98
        $fileUploadChunks = $this->getFileUploadChunks();
1✔
99
        if (count($fileUploadChunks) === 0) {
1✔
100
            return;
1✔
101
        }
102

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

116
    /**
117
     * @return FileUpload[]
118
     */
119
    public function getValue(): array
120
    {
121
        if ($this->isDisabled()) {
1✔
122
            return [];
1✔
123
        }
124

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

132
    /**
133
     * @param mixed $value
134
     * @return static
135
     * @internal
136
     */
137
    public function setValue($value): self
138
    {
139
        return $this;
1✔
140
    }
141

142
    public function isDisabled(): bool
143
    {
144
        return $this->getUploadControl()->isDisabled();
1✔
145
    }
146

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

160
        $form = $this->getForm(false);
1✔
161
        if ($form !== null && $form->isAnchored() && (bool) $form->isSubmitted()) {
1✔
162
            $this->loadHttpData();
×
163
        }
164

165
        return $this;
1✔
166
    }
167

168
    public function getControlPart(?string $key = null): ?Html
1✔
169
    {
170
        if ($key === 'namespace') {
1✔
171
            return $this->getNamespaceControl()->getControl();
1✔
172
        }
173

174
        if ($key === 'upload') {
1✔
175
            $control = $this->getUploadControl()->getControl();
1✔
176
            assert($control instanceof Html);
1✔
177
            $control->{'data-nette-rules'} = Nette\Forms\Helpers::exportRules($this->getRules());
1✔
178
            return $control;
1✔
179
        }
180

181
        return $this->getControl();
×
182
    }
183

184
    public function getControl(): Html
185
    {
186
        $this->setOption('rendered', true);
1✔
187
        $control = clone $this->control;
1✔
188

189
        $template = $this->getTemplate();
1✔
190
        assert($template instanceof Template);
1✔
191

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

204
        $template->uploadUrl = $this->link('upload!', ['namespace' => $this->getUploadNamespace()->toString()]);
1✔
205
        $template->uniqueFilenames = $uniqueFilenames;
1✔
206
        $template->completedFiles = $completedFiles;
1✔
207

208
        $controlHtml = Nette\Utils\Helpers::capture(function () use ($template): void {
1✔
209
            $template->render($this->templateFile);
1✔
210
        });
1✔
211
        return $control->addHtml($controlHtml);
1✔
212
    }
213

214
    public function getLabelPrototype(): Html
215
    {
216
        return $this->getUploadControl()->getLabelPrototype();
1✔
217
    }
218

219
    /**
220
     * @param string|Html|null $caption
221
     * @return Html
222
     */
223
    public function getLabel($caption = null): Html
1✔
224
    {
225
        $label = $this->getUploadControl()->getLabel($caption);
1✔
226
        assert($label instanceof Html);
1✔
227
        return $label;
1✔
228
    }
229

230
    /**
231
     * @param callable|string $validator
232
     * @param string|Html|null $errorMessage
233
     * @param mixed $arg
234
     * @return static
235
     */
236
    public function addRule($validator, $errorMessage = null, $arg = null): self
1✔
237
    {
238
        if ($validator === Form::IMAGE) {
1✔
239
            $this->getUploadControl()->setHtmlAttribute('accept', implode(',', FileUpload::IMAGE_MIME_TYPES));
1✔
240
        } elseif ($validator === Form::MIME_TYPE) {
1✔
241
            $mimeTypes = is_array($arg) ? $arg : ($arg === null ? [] : [$arg]);
1✔
242
            $this->getUploadControl()->setHtmlAttribute('accept', implode(',', $mimeTypes));
1✔
243
        }
244

245
        return $this->_addRule($validator, $errorMessage, $arg);
1✔
246
    }
247

248
    /**
249
     * @param string $namespace
250
     * @throws Nette\Application\BadRequestException
251
     */
252
    public function handleUpload(string $namespace): void
1✔
253
    {
254
        $uploadNamespace = $this->parseUploadNamespace($namespace);
1✔
255
        $this->setUploadNamespace($uploadNamespace);
1✔
256

257
        $fileUploadChunks = $this->getFileUploadChunks();
1✔
258
        $responses = [];
1✔
259
        foreach ($fileUploadChunks as $fileUploadChunk) {
1✔
260
            if ($this->isDisabled()) {
1✔
261
                $responses[] = $this->createUploadErrorResponse($fileUploadChunk, $this->translate('Upload disabled'));
1✔
262
                continue;
1✔
263
            }
264

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

279
            try {
280
                $fileUploadItem = $this->getStorage()->save($fileUploadChunk);
1✔
281
                $responses[] = $this->createUploadSuccessResponse($fileUploadItem);
1✔
282
            } catch (StorageDoesNotExistException | UnableToSaveFileUploadException $exception) {
×
283
                $responses[] = $this->createUploadErrorResponse($fileUploadChunk, $this->translate('Upload error'));
×
284
            }
285
        }
286

287
        $this->sendJson(new ListResponse(...$responses));
1✔
288
    }
289

290
    /**
291
     * @param string $namespace
292
     * @param string $id
293
     * @throws Nette\Application\BadRequestException
294
     */
295
    public function handleDelete(string $namespace, string $id): void
296
    {
297
        $fileUploadId = $this->parseFileUploadId($id);
×
298
        $uploadNamespace = $this->parseUploadNamespace($namespace);
×
299
        $this->setUploadNamespace($uploadNamespace);
×
300

301
        try {
302
            $this->getStorage()->delete($fileUploadId);
×
303
        } catch (StorageDoesNotExistException $exception) {
×
304
            // noop
305
        }
306
        $this->sendJson('');
×
307
    }
308

309
    /**
310
     * @param string $namespace
311
     * @param string $id
312
     * @throws Nette\Application\BadRequestException
313
     */
314
    public function handleDownload(string $namespace, string $id): void
1✔
315
    {
316
        $fileUploadId = $this->parseFileUploadId($id);
1✔
317
        $uploadNamespace = $this->parseUploadNamespace($namespace);
1✔
318
        $this->setUploadNamespace($uploadNamespace);
1✔
319

320
        try {
321
            $fileUploadItem = $this->getStorage()->load($fileUploadId);
1✔
322
        } catch (StorageDoesNotExistException | FileUploadNotFoundException $exception) {
×
323
            throw new Nette\Application\BadRequestException('File upload not found.', 0, $exception);
×
324
        }
325

326
        $fileUpload = $fileUploadItem->getFileUpload();
1✔
327
        $response = new Nette\Application\Responses\FileResponse(
1✔
328
            $fileUpload->getTemporaryFile(),
1✔
329
            $fileUpload->getUntrustedName(),
1✔
330
        );
331
        $this->getPresenter()->sendResponse($response);
1✔
332
    }
333

334
    /**
335
     * @param string $namespace
336
     * @param string $id
337
     * @throws Nette\Application\BadRequestException
338
     */
339
    public function handleThumbnail(string $namespace, string $id): void
340
    {
341
        $fileUploadId = $this->parseFileUploadId($id);
×
342
        $uploadNamespace = $this->parseUploadNamespace($namespace);
×
343
        $this->setUploadNamespace($uploadNamespace);
×
344

345
        try {
346
            $fileUpload = $this->getStorage()->load($fileUploadId)->getFileUpload();
×
347
        } catch (StorageDoesNotExistException | FileUploadNotFoundException $exception) {
×
348
            throw new Nette\Application\BadRequestException('Source file for thumbnail not found.', 0, $exception);
×
349
        }
350

351
        if (! $this->thumbnailProvider->isSupported($fileUpload)) {
×
352
            throw new Nette\Application\BadRequestException('Thumbnail could not be generated');
×
353
        }
354

355
        $response = $this->thumbnailProvider->createThumbnail($fileUpload);
×
356
        $this->getPresenter()->sendResponse($response);
×
357
    }
358

359
    protected function createTemplate(): Template
360
    {
361
        $template = parent::createTemplate();
1✔
362
        assert($template instanceof Template);
1✔
363

364
        $translator = $this->getTranslator();
1✔
365
        if ($translator !== null) {
1✔
366
            $template->setTranslator($translator);
1✔
367
        }
368

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

371
        return $template;
1✔
372
    }
373

374
    protected function getHttpRequest(): Nette\Http\IRequest
375
    {
376
        return $this->getPresenter()->getHttpRequest();
1✔
377
    }
378

379
    /**
380
     * Sends back JSON response.
381
     * Sets the right content type based on the support on the other end.
382
     * https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#wiki-content-type-negotiation
383
     *
384
     * @param mixed $data
385
     */
386
    protected function sendJson($data): void
387
    {
388
        $contentType = Strings::contains((string) $this->getHttpRequest()->getHeader('accept'), 'application/json') ? 'application/json' : 'text/plain';
1✔
389
        $response = new Nette\Application\Responses\JsonResponse($data, $contentType);
1✔
390
        $this->getPresenter()->sendResponse($response);
1✔
391
    }
392

393
    protected function getUploadControl(): Nette\Forms\Controls\UploadControl
394
    {
395
        return $this->getComponent('upload');
1✔
396
    }
397

398
    protected function getNamespaceControl(): Nette\Forms\Controls\HiddenField
399
    {
400
        return $this->getComponent('namespace');
1✔
401
    }
402

403
    protected function getThumbnailProvider(): ThumbnailProvider
404
    {
405
        return $this->thumbnailProvider;
×
406
    }
407

408
    protected function getUploadNamespace(): UploadNamespace
409
    {
410
        $nameSpaceValue = (string) $this->getNamespaceControl()->getValue();
1✔
411
        if (UploadNamespace::isValid($nameSpaceValue)) {
1✔
412
            return UploadNamespace::fromString($nameSpaceValue);
1✔
413
        }
414

415
        $namespace = $this->storageManager->createNewNamespace();
×
416
        $this->setUploadNamespace($namespace);
×
417
        return $namespace;
×
418
    }
419

420
    protected function setUploadNamespace(UploadNamespace $namespace): void
1✔
421
    {
422
        $this->getNamespaceControl()->setValue($namespace->toString());
1✔
423
    }
1✔
424

425
    /**
426
     * @return Storage
427
     * @throws StorageDoesNotExistException
428
     */
429
    protected function getStorage(): Storage
430
    {
431
        return $this->storageManager->getStorage($this->getUploadNamespace());
1✔
432
    }
433

434
    protected function createUploadSuccessResponse(FileUploadItem $fileUploadItem): Response
1✔
435
    {
436
        $fileUpload = $fileUploadItem->getFileUpload();
1✔
437
        $idValue = $fileUploadItem->getId()->toString();
1✔
438
        $namespaceValue = $this->getUploadNamespace()->toString();
1✔
439
        return new UploadSuccessResponse(
1✔
440
            $fileUpload->getUntrustedName(),
1✔
441
            $fileUpload->getSize(),
1✔
442
            $fileUpload->getContentType(),
1✔
443
            $this->link('download!', ['namespace' => $namespaceValue, 'id' => $idValue]),
1✔
444
            $this->link('delete!', ['namespace' => $namespaceValue, 'id' => $idValue]),
1✔
445
            $this->thumbnailProvider->isSupported($fileUpload) ? $this->link('thumbnail!', ['namespace' => $namespaceValue, 'id' => $idValue]) : null,
1✔
446
        );
447
    }
448

449
    protected function createUploadErrorResponse(FileUploadChunk $fileUploadChunk, string $error): Response
1✔
450
    {
451
        return new UploadErrorResponse(
1✔
452
            $fileUploadChunk->getFileUpload()->getUntrustedName(),
1✔
453
            $fileUploadChunk->getContentRange()->getSize(),
1✔
454
            $error,
455
        );
456
    }
457

458
    /**
459
     * @param string $value
460
     * @return UploadNamespace
461
     * @throws Nette\Application\BadRequestException
462
     */
463
    protected function parseUploadNamespace(string $value): UploadNamespace
1✔
464
    {
465
        if (! UploadNamespace::isValid($value)) {
1✔
466
            throw new Nette\Application\BadRequestException('Invalid namespace value', 400);
×
467
        }
468
        return UploadNamespace::fromString($value);
1✔
469
    }
470

471
    /**
472
     * @param string $value
473
     * @return FileUploadId
474
     * @throws Nette\Application\BadRequestException
475
     */
476
    protected function parseFileUploadId(string $value): FileUploadId
1✔
477
    {
478
        if (! FileUploadId::isValid($value)) {
1✔
479
            throw new Nette\Application\BadRequestException('Invalid file upload id value', 400);
×
480
        }
481
        return FileUploadId::fromString($value);
1✔
482
    }
483

484
    /**
485
     * @return FileUploadChunk[]
486
     * @throws Nette\Application\BadRequestException
487
     */
488
    protected function getFileUploadChunks(): array
489
    {
490
        $httpRequest = $this->getHttpRequest();
1✔
491
        /** @var array<int, FileUpload> $files */
492
        $files = Nette\Forms\Helpers::extractHttpData($httpRequest->getFiles(), $this->getUploadControl()->getHtmlName() . '[]', Form::DATA_FILE);
1✔
493
        if (count($files) === 0) {
1✔
494
            return [];
1✔
495
        }
496

497
        $contentRangeHeaderValue = $httpRequest->getHeader('content-range');
1✔
498
        if ($contentRangeHeaderValue !== null) {
1✔
499
            if (count($files) > 1) {
1✔
500
                throw new Nette\Application\BadRequestException('Chunk upload does not support multi-file upload', 400);
×
501
            }
502
            try {
503
                $contentRange = ContentRange::fromHttpHeaderValue($contentRangeHeaderValue);
1✔
504
                $fileUploadChunk = FileUploadChunk::partialUpload(reset($files), $contentRange);
1✔
505
                return [$fileUploadChunk];
1✔
506
            } catch (\Throwable $exception) {
×
507
                throw new Nette\Application\BadRequestException('Invalid content-range header value', 400, $exception);
×
508
            }
509
        }
510

511
        /** @var FileUploadChunk[] $fileUploadChunks */
512
        $fileUploadChunks = [];
1✔
513
        foreach ($files as $file) {
1✔
514
            $fileUploadChunks[] = FileUploadChunk::completeUpload($file);
1✔
515
        }
516

517
        return $fileUploadChunks;
1✔
518
    }
519

520
}
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