Uploads   A
last analyzed

Complexity

Total Complexity 38

Size/Duplication

Total Lines 369
Duplicated Lines 0 %

Test Coverage

Coverage 98.84%

Importance

Changes 5
Bugs 1 Features 1
Metric Value
eloc 153
c 5
b 1
f 1
dl 0
loc 369
ccs 171
cts 173
cp 0.9884
rs 9.36
wmc 38

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 2
A readOne() 0 9 1
A readAll() 0 14 2
A readBinary() 0 11 1
A getExtensionOrExplode() 0 7 2
A archive() 0 10 2
A destroyAll() 0 10 2
A nuke() 0 6 2
A postAction() 0 11 2
A getPage() 0 3 1
A getStorageFromLongname() 0 7 1
A readNormalAndArchived() 0 12 1
A create() 0 94 3
A update() 0 7 1
A patch() 0 12 3
A createFromString() 0 17 3
A canWriteOrExplode() 0 6 2
A setId() 0 8 2
A destroy() 0 9 2
A pngDataUrlToBinary() 0 8 2
A replace() 0 6 1
1
<?php declare(strict_types=1);
2
/**
3
 * @author Nicolas CARPi <[email protected]>
4
 * @copyright 2012, 2022 Nicolas CARPi
5
 * @see https://www.elabftw.net Official website
6
 * @license AGPL-3.0
7
 * @package elabftw
8
 */
9
10
namespace Elabftw\Models;
11
12
use Elabftw\Controllers\DownloadController;
13
use Elabftw\Elabftw\CreateUpload;
14
use Elabftw\Elabftw\Db;
15
use Elabftw\Elabftw\FsTools;
16
use Elabftw\Elabftw\Tools;
17
use Elabftw\Elabftw\UploadParams;
18
use Elabftw\Enums\Action;
19
use Elabftw\Enums\FileFromString;
20
use Elabftw\Enums\State;
21
use Elabftw\Enums\Storage;
22
use Elabftw\Exceptions\IllegalActionException;
23
use Elabftw\Exceptions\ImproperActionException;
24
use Elabftw\Factories\MakeThumbnailFactory;
25
use Elabftw\Interfaces\CreateUploadParamsInterface;
26
use Elabftw\Interfaces\RestInterface;
27
use Elabftw\Services\Check;
28
use Elabftw\Traits\UploadTrait;
29
use function hash_file;
30
use ImagickException;
31
use League\Flysystem\UnableToRetrieveMetadata;
0 ignored issues
show
Bug introduced by
The type League\Flysystem\UnableToRetrieveMetadata was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
32
use PDO;
33
use RuntimeException;
34
use Symfony\Component\HttpFoundation\Response;
0 ignored issues
show
Bug introduced by
The type Symfony\Component\HttpFoundation\Response was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
35
36
/**
37
 * All about the file uploads
38
 */
39
class Uploads implements RestInterface
40
{
41
    use UploadTrait;
42
43
    public const HASH_ALGORITHM = 'sha256';
44
45
    /** @var int BIG_FILE_THRESHOLD size of a file in bytes above which we don't process it (50 Mb) */
46
    private const BIG_FILE_THRESHOLD = 50000000;
47
48
    public array $uploadData = array();
49
50
    public bool $includeArchived = false;
51
52
    protected Db $Db;
53
54 202
    public function __construct(public AbstractEntity $Entity, public ?int $id = null)
55
    {
56 202
        $this->Db = Db::getConnection();
57 202
        if ($this->id !== null) {
58 9
            $this->readOne();
59
        }
60
    }
61
62
    /**
63
     * Main method for normal file upload
64
     * @psalm-suppress UndefinedClass
65
     */
66 29
    public function create(CreateUploadParamsInterface $params): int
67
    {
68 29
        $this->Entity->canOrExplode('write');
69
70
        // original file name
71 29
        $realName = $params->getFilename();
72 29
        $ext = $this->getExtensionOrExplode($realName);
73
74
        // name for the stored file, includes folder and extension (ab/ab34[...].ext)
75 28
        $longName = $this->getLongName() . '.' . $ext;
76 28
        $folder = substr($longName, 0, 2);
77
78
        // where our uploaded file lives
79 28
        $sourceFs = $params->getSourceFs();
80
        // where we want to store it
81 28
        $Config = Config::getConfig();
82 28
        $storage = (int) $Config->configArr['uploads_storage'];
83 28
        $storageFs = Storage::from($storage)->getStorage()->getFs();
84
85 28
        $tmpFilename = basename($params->getFilePath());
86 28
        $filesize = $sourceFs->filesize($tmpFilename);
87 28
        $hash = '';
88
        // we don't hash big files as this could take too much time/resources
89
        // same with thumbnails
90 28
        if ($filesize < self::BIG_FILE_THRESHOLD) {
91
            // get a hash sum
92 28
            $hash = hash_file(self::HASH_ALGORITHM, $params->getFilePath());
93
            // get a thumbnail
94
            // Imagick cannot open password protected PDFs, thumbnail generation will throw ImagickException
95
            try {
96 28
                MakeThumbnailFactory::getMaker(
97 28
                    $sourceFs->mimeType($tmpFilename),
98 28
                    $params->getFilePath(),
99 28
                    $longName,
100 28
                    $storageFs,
101 28
                )->saveThumb();
102 3
            } catch (UnableToRetrieveMetadata | ImagickException) {
103
                // if mime type could not be read just ignore it and continue
104
                // if imagick/imagemagick causes problems ignore it and upload file without thumbnail
105
            }
106
        }
107
        // read the file as a stream so we can copy it
108 28
        $inputStream = $sourceFs->readStream($tmpFilename);
109
110 28
        $storageFs->createDirectory($folder);
111 28
        $storageFs->writeStream($longName, $inputStream);
112
113 28
        $this->Entity->touch();
114
115
        // final sql
116 28
        $sql = 'INSERT INTO uploads(
117
            real_name,
118
            long_name,
119
            comment,
120
            item_id,
121
            userid,
122
            type,
123
            hash,
124
            hash_algorithm,
125
            state,
126
            storage,
127
            filesize,
128
            immutable
129
        ) VALUES(
130
            :real_name,
131
            :long_name,
132
            :comment,
133
            :item_id,
134
            :userid,
135
            :type,
136
            :hash,
137
            :hash_algorithm,
138
            :state,
139
            :storage,
140
            :filesize,
141
            :immutable
142 28
        )';
143
144 28
        $req = $this->Db->prepare($sql);
145 28
        $req->bindParam(':real_name', $realName);
146 28
        $req->bindParam(':long_name', $longName);
147 28
        $req->bindValue(':comment', $params->getComment());
148 28
        $req->bindParam(':item_id', $this->Entity->id, PDO::PARAM_INT);
149 28
        $req->bindParam(':userid', $this->Entity->Users->userData['userid'], PDO::PARAM_INT);
150 28
        $req->bindParam(':type', $this->Entity->type);
151 28
        $req->bindParam(':hash', $hash);
152 28
        $req->bindValue(':hash_algorithm', self::HASH_ALGORITHM);
153 28
        $req->bindValue(':state', $params->getState()->value, PDO::PARAM_INT);
154 28
        $req->bindParam(':storage', $storage, PDO::PARAM_INT);
155 28
        $req->bindParam(':filesize', $filesize, PDO::PARAM_INT);
156 28
        $req->bindValue(':immutable', $params->getImmutable(), PDO::PARAM_INT);
157 28
        $this->Db->execute($req);
158
159 28
        return $this->Db->lastInsertId();
160
    }
161
162
    /**
163
     * Read from current id
164
     */
165 19
    public function readOne(): array
166
    {
167 19
        $sql = 'SELECT uploads.*, CONCAT (users.firstname, " ", users.lastname) AS fullname
168 19
            FROM uploads LEFT JOIN users ON (uploads.userid = users.userid) WHERE id = :id';
169 19
        $req = $this->Db->prepare($sql);
170 19
        $req->bindParam(':id', $this->id, PDO::PARAM_INT);
171 19
        $this->Db->execute($req);
172 19
        $this->uploadData = $this->Db->fetch($req);
173 19
        return $this->uploadData;
174
    }
175
176
    /**
177
     * Read an upload in binary format, so the actual file uploaded
178
     */
179 1
    public function readBinary(): Response
180
    {
181 1
        $storageFs = Storage::from($this->uploadData['storage'])->getStorage()->getFs();
182
183 1
        $DownloadController = new DownloadController(
184 1
            $storageFs,
185 1
            $this->uploadData['long_name'],
186 1
            $this->uploadData['real_name'],
187 1
            true,
188 1
        );
189 1
        return $DownloadController->getResponse();
190
    }
191
192
    /**
193
     * Read only the normal ones (not archived/deleted)
194
     */
195 173
    public function readAll(): array
196
    {
197 173
        if ($this->includeArchived) {
198 1
            return $this->readNormalAndArchived();
199
        }
200 173
        $sql = 'SELECT uploads.*, CONCAT (users.firstname, " ", users.lastname) AS fullname
201 173
            FROM uploads LEFT JOIN users ON (uploads.userid = users.userid) WHERE item_id = :id AND type = :type AND state = :state ORDER BY created_at DESC';
202 173
        $req = $this->Db->prepare($sql);
203 173
        $req->bindParam(':id', $this->Entity->id, PDO::PARAM_INT);
204 173
        $req->bindParam(':type', $this->Entity->type);
205 173
        $req->bindValue(':state', State::Normal->value, PDO::PARAM_INT);
206 173
        $this->Db->execute($req);
207
208 173
        return $req->fetchAll();
209
    }
210
211 2
    public function patch(Action $action, array $params): array
212
    {
213 2
        $this->canWriteOrExplode();
214 1
        $this->Entity->touch();
215 1
        if ($action === Action::Archive) {
216 1
            return $this->archive();
217
        }
218 1
        unset($params['action']);
219 1
        foreach ($params as $key => $value) {
220 1
            $this->update(new UploadParams($key, $value));
221
        }
222 1
        return $this->readOne();
223
    }
224
225 11
    public function postAction(Action $action, array $reqBody): int
226
    {
227 11
        $this->Entity->touch();
228 11
        if ($this->id !== null) {
229 1
            $action = Action::Replace;
230
        }
231 11
        return match ($action) {
232 11
            Action::Create => $this->create(new CreateUpload($reqBody['real_name'], $reqBody['filePath'], $reqBody['comment'])),
233 11
            Action::CreateFromString => $this->createFromString(FileFromString::from($reqBody['file_type']), $reqBody['real_name'], $reqBody['content']),
234 11
            Action::Replace => $this->replace(new CreateUpload($reqBody['real_name'], $reqBody['filePath'])),
235 11
            default => throw new ImproperActionException('Invalid action for upload creation.'),
236 11
        };
237
    }
238
239 1
    public function getPage(): string
240
    {
241 1
        return sprintf('api/v2/%s/%d/uploads/', $this->Entity->page, $this->Entity->id ?? 0);
242
    }
243
244
    /**
245
     * Make a body check and then remove upload
246
     */
247 1
    public function destroy(): bool
248
    {
249 1
        $this->canWriteOrExplode();
250 1
        $this->Entity->touch();
251
        // check that the filename is not in the body. see #432
252 1
        if (strpos($this->Entity->entityData['body'] ?? '', $this->uploadData['long_name'])) {
253
            throw new ImproperActionException(_('Please make sure to remove any reference to this file in the body!'));
254
        }
255 1
        return $this->nuke();
256
    }
257
258 11
    public function setId(int $id): void
259
    {
260 11
        if (Check::id($id) === false) {
261 1
            throw new IllegalActionException('The id parameter is not valid!');
262
        }
263 10
        $this->id = $id;
264
        // load it
265 10
        $this->readOne();
266
    }
267
268
    /**
269
     * Delete all uploaded files for an entity
270
     */
271 1
    public function destroyAll(): bool
272
    {
273
        // this will include the archived/deleted ones
274 1
        $uploadArr = $this->readAll();
275
276 1
        foreach ($uploadArr as $upload) {
277 1
            $this->setId($upload['id']);
278 1
            $this->nuke();
279
        }
280 1
        return true;
281
    }
282
283 2
    public function getStorageFromLongname(string $longname): int
284
    {
285 2
        $sql = 'SELECT storage FROM uploads WHERE long_name = :long_name LIMIT 1';
286 2
        $req = $this->Db->prepare($sql);
287 2
        $req->bindParam(':long_name', $longname, PDO::PARAM_STR);
288 2
        $this->Db->execute($req);
289 2
        return (int) $req->fetchColumn();
290
    }
291
292 4
    private function update(UploadParams $params): bool
293
    {
294 4
        $sql = 'UPDATE uploads SET ' . $params->getColumn() . ' = :content WHERE id = :id';
295 4
        $req = $this->Db->prepare($sql);
296 4
        $req->bindValue(':content', $params->getContent());
297 4
        $req->bindParam(':id', $this->id, PDO::PARAM_INT);
298 4
        return $this->Db->execute($req);
299
    }
300
301 1
    private function readNormalAndArchived(): array
302
    {
303 1
        $sql = 'SELECT uploads.*, CONCAT (users.firstname, " ", users.lastname) AS fullname
304 1
            FROM uploads LEFT JOIN users ON (uploads.userid = users.userid) WHERE item_id = :id AND type = :type AND (state = :normal OR state = :archived) ORDER BY uploads.created_at DESC';
305 1
        $req = $this->Db->prepare($sql);
306 1
        $req->bindParam(':id', $this->Entity->id, PDO::PARAM_INT);
307 1
        $req->bindParam(':type', $this->Entity->type);
308 1
        $req->bindValue(':normal', State::Normal->value, PDO::PARAM_INT);
309 1
        $req->bindValue(':archived', State::Archived->value, PDO::PARAM_INT);
310 1
        $this->Db->execute($req);
311
312 1
        return $req->fetchAll();
313
314
    }
315
316
    /**
317
     * Attached files are immutable (change history is kept), so the current
318
     * file gets its state changed to "archived" and a new one is added
319
     */
320 1
    private function replace(CreateUpload $params): int
321
    {
322
        // read the current one to get the comment, and at the same time archive it
323 1
        $upload = $this->archive();
324
325 1
        return $this->create(new CreateUpload($params->getFilename(), $params->getFilePath(), $upload['comment']));
326
    }
327
328
    /**
329
     * Create an upload from a string (binary png data or json string or mol file)
330
     * For mol file the code is actually in chemdoodle-uis-unpacked.js from chemdoodle-web-mini repository
331
     */
332 10
    private function createFromString(FileFromString $fileType, string $realName, string $content): int
333
    {
334
        // a png file will be received as dataurl, so we need to convert it to binary before saving it
335 10
        if ($fileType === FileFromString::Png) {
336 2
            $content = $this->pngDataUrlToBinary($content);
337
        }
338
339
        // add file extension if it wasn't provided
340 9
        if (Tools::getExt($realName) === 'unknown') {
341 1
            $realName .= '.' . $fileType->value;
342
        }
343
        // create a temporary file so we can upload it using create()
344 9
        $tmpFilePath = FsTools::getCacheFile();
345 9
        $tmpFilePathFs = FsTools::getFs(dirname($tmpFilePath));
346 9
        $tmpFilePathFs->write(basename($tmpFilePath), $content);
347
348 9
        return $this->create(new CreateUpload($realName, $tmpFilePath));
349
    }
350
351
    /**
352
     * Transform a png data url into its binary form
353
     */
354 2
    private function pngDataUrlToBinary(string $content): string
355
    {
356 2
        $content = str_replace(array('data:image/png;base64,', ' '), array('', '+'), $content);
357 2
        $content = base64_decode($content, true);
358 2
        if ($content === false) {
359 1
            throw new RuntimeException('Could not decode content!');
360
        }
361 1
        return $content;
362
    }
363
364 4
    private function canWriteOrExplode(): void
365
    {
366 4
        if ($this->uploadData['immutable'] === 1) {
367 1
            throw new IllegalActionException('User tried to edit an immutable upload.');
368
        }
369 3
        $this->Entity->canOrExplode('write');
370
    }
371
372 2
    private function archive(): array
373
    {
374 2
        $this->canWriteOrExplode();
375 2
        $targetState = State::Archived->value;
376
        // if already archived, unarchive
377 2
        if ($this->uploadData['state'] === State::Archived->value) {
378
            $targetState = State::Normal->value;
379
        }
380 2
        $this->update(new UploadParams('state', (string) $targetState));
381 2
        return $this->readOne();
382
    }
383
384
    /**
385
     * This function will not remove the files but set them to "deleted" state
386
     * A manual purge must be made by sysadmin if they wish to really remove them.
387
     */
388 2
    private function nuke(): bool
389
    {
390 2
        if ($this->uploadData['immutable'] === 0) {
391 2
            return $this->update(new UploadParams('state', (string) State::Deleted->value));
392
        }
393 1
        return false;
394
    }
395
396
    /**
397
     * Check if extension is allowed for upload
398
     *
399
     * @param string $realName The name of the file
400
     */
401 29
    private function getExtensionOrExplode(string $realName): string
402
    {
403 29
        $ext = Tools::getExt($realName);
404 29
        if ($ext === 'php') {
405 1
            throw new ImproperActionException('PHP files are forbidden!');
406
        }
407 28
        return $ext;
408
    }
409
}
410