Completed
Push — master ( 287393...7ff50d )
by Raffael
18:27 queued 14:12
created

src/app/Balloon.App.Api/v2/Files.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * balloon
7
 *
8
 * @copyright   Copryright (c) 2012-2019 gyselroth GmbH (https://gyselroth.com)
9
 * @license     GPL-3.0 https://opensource.org/licenses/GPL-3.0
10
 */
11
12
namespace Balloon\App\Api\v2;
13
14
use Balloon\Filesystem\Acl\Exception\Forbidden as ForbiddenException;
15
use Balloon\Filesystem\Exception;
16
use Balloon\Filesystem\Node\Collection;
17
use Balloon\Filesystem\Node\NodeInterface;
18
use Balloon\Filesystem\Storage\Adapter\AdapterInterface as StorageAdapterInterface;
19
use Balloon\Server\AttributeDecorator as RoleAttributeDecorator;
20
use Micro\Http\Response;
21
use MongoDB\BSON\ObjectId;
22
23
class Files extends Nodes
24
{
25
    /**
26
     * @api {get} /api/v2/files/:id/history Get history
27
     * @apiVersion 2.0.0
28
     * @apiName getHistory
29
     * @apiGroup Node\File
30
     * @apiPermission none
31
     * @apiDescription Get a full change history of a file
32
     * @apiUse _getNode
33
     *
34
     * @apiExample (cURL) example:
35
     * curl -XGET "https://SERVER/api/v2/files/history?id=544627ed3c58891f058b4686&pretty"
36
     * curl -XGET "https://SERVER/api/v2/files/544627ed3c58891f058b4686/history?pretty"
37
     * curl -XGET "https://SERVER/api/v2/files/history?p=/absolute/path/to/my/file&pretty"
38
     *
39
     * @apiSuccess (200 OK) {object[]} - History
40
     * @apiSuccess (200 OK) {number} -.version Version
41
     * @apiSuccess (200 OK) {string} -.changed ISO806 timestamp
42
     * @apiSuccess (200 OK) {object} -.user User object
43
     * @apiSuccess (200 OK) {number} -.type Change type, there are five different change types including:</br>
44
     *  0 - Initially added</br>
45
     *  1 - Content modified</br>
46
     *  2 - Version rollback</br>
47
     *  3 - Deleted</br>
48
     *  4 - Undeleted
49
     * @apiSuccess (200 OK) {number} -.size Content size in bytes
50
     * @apiSuccessExample {json} Success-Response:
51
     * HTTP/1.1 200 OK
52
     * [
53
     *  {
54
     *      "version": 1,
55
     *      "changed": ""
56
     *      "user": {
57
     *          "id": "544627ed3c58891f058b4611",
58
     *          "name": "peter.meier"
59
     *      },
60
     *      "type": 0,
61
     *      "size": 178,
62
     *  }
63
     * ]
64
     *
65
     * @param string $id
66
     * @param string $p
67
     */
68
    public function getHistory(RoleAttributeDecorator $role_decorator, ?string $id = null, ?string $p = null): Response
69
    {
70
        $result = $this->_getNode($id, $p)->getHistory();
71
        $body = [];
72
        foreach ($result as $version) {
73
            if ($version['user'] === null) {
74
                $user = null;
75
            } else {
76
                $user = $this->server->getUserById($version['user']);
77
                $user = $role_decorator->decorate($user, ['id', 'name', '_links']);
78
            }
79
80
            $body[] = [
81
                'version' => $version['version'],
82
                'changed' => $version['changed']->toDateTime()->format('c'),
83
                'type' => $version['type'],
84
                'size' => $version['size'],
85
                'user' => $user,
86
            ];
87
        }
88
89
        return (new Response())->setCode(200)->setBody(['data' => $body]);
90
    }
91
92
    /**
93
     * @api {post} /api/v2/files/:id/restore Rollback version
94
     * @apiVersion 2.0.0
95
     * @apiName postRestore
96
     * @apiGroup Node\File
97
     * @apiPermission none
98
     * @apiDescription Rollback to a recent version from history. Use the version number from history.
99
     * @apiUse _getNode
100
     *
101
     * @apiExample (cURL) example:
102
     * curl -XPOST "https://SERVER/api/v2/files/restore?id=544627ed3c58891f058b4686&pretty&vesion=11"
103
     * curl -XPOST "https://SERVER/api/v2/files/544627ed3c58891f058b4686/restore?pretty&version=1"
104
     * curl -XPOST "https://SERVER/api/v2/files/restore?p=/absolute/path/to/my/file&pretty&version=3"
105
     *
106
     * @apiParam (GET Parameter) {number} version The version from history to rollback to
107
     *
108
     * @apiSuccessExample {json} Success-Response:
109
     * HTTP/1.1 200 OK
110
     * {
111
     *      "id": "544627ed3c58891f058b4686"
112
     * }
113
     *
114
     * @param string $id
115
     * @param string $p
116
     * @param string $version
117
     */
118
    public function postRestore(int $version, ?string $id = null, ?string $p = null): Response
119
    {
120
        $node = $this->_getNode($id, $p);
121
        $node->restore($version);
122
        $result = $this->node_decorator->decorate($node);
123
124
        return (new Response())->setCode(200)->setBody($result);
125
    }
126
127
    /**
128
     * @api {put} /api/v2/files/chunk Upload file chunk
129
     * @apiVersion 2.0.0
130
     * @apiName putChunk
131
     * @apiGroup Node\File
132
     * @apiPermission none
133
     * @apiUse _getNode
134
     * @apuUse _conflictNode
135
     * @apiUse _writeAction
136
     * @apiDescription Upload a file chunk. Use this method if you have possible big files!
137
     * You have to manually splitt the binary data into
138
     * multiple chunks and upload them successively using this method. Once uploading the last chunk,
139
     * the server will automatically create or update the file node.
140
     * You may set the parent collection, name and or custom attributes only with the last request to save traffic.
141
     *
142
     * @apiExample (cURL) example:
143
     * # Upload a new file myfile.jpg into the collection 544627ed3c58891f058b4686.
144
     * 1. First splitt the file into multiple 8M (For example, you could also use a smaller or bigger size) chunks
145
     * 2. Create a unique name for the session (Could also be the filename), best thing is to create a UUIDv4
146
     * 3. Upload each chunk successively (follow the binary order of your file!) using the chunk PUT method
147
     *   (The server identifies each chunk with the index parameter, beginning with #1).
148
     * 4. If chunk number 3 will be reached, the server automatically place all chunks to the new file node
149
     *
150
     * curl -XPUT "https://SERVER/api/v2/files/chunk?collection=544627ed3c58891f058b4686&name=myfile.jpg&index=1&chunks=3&session=myuniquesession&size=12342442&pretty" --data-binary @chunk1.bin
151
     * curl -XPUT "https://SERVER/api/v2/files/chunk?collection=544627ed3c58891f058b4686&name=myfile.jpg&index=2&chunks=3&session=myuniquesession&size=12342442&pretty" --data-binary @chunk2.bin
152
     * curl -XPUT "https://SERVER/api/v2/files/chunk?collection=544627ed3c58891f058b4686&name=myfile.jpg&index=3&chunks=3&session=myuniquesession&size=12342442&pretty" --data-binary @chunk3.bin
153
     *
154
     * @apiParam (GET Parameter) {string} [id] Either id, p (path) of a file node or a parent collection id must be given
155
     * @apiParam (GET Parameter) {string} [p] Either id, p (path) of a file node or a parent collection id must be given
156
     * @apiParam (GET Parameter) {string} [collection] Either id, p (path) of a file node or a parent collection id must be given
157
     * (If none of them are given, the file will be placed to the root)
158
     * @apiParam (GET Parameter) {string} [name] Needs to be set if the chunk belongs to a new file
159
     * @apiParam (GET Parameter) {number} index Chunk ID
160
     * @apiParam (GET Parameter) {number} chunks Total number of chunks
161
     * @apiParam (GET Parameter) {string} session Session ID you have received during uploading the first chunk
162
     * @apiParam (GET Parameter) {number} size The total file size in bytes
163
     * @apiParam (GET Parameter) {string} [created] Set specific created ISO806 timestamp
164
     * @apiParam (GET Parameter) {string} [changed] Set specific changed ISO806 timestamp
165
     * @apiParam (GET Parameter) {bool} [readonly] Mark node readonly
166
     * @apiParam (GET Parameter) {object} [meta] Meta attributes
167
     *
168
     *
169
     * @apiSuccess (200 OK) {number} status Status Code
170
     * @apiSuccess (200 OK) {number} data Increased version number if the last chunk was uploaded and existing node was updated.
171
     * It will return the old version if the submited file content was equal to the existing one.
172
     *
173
     * @apiSuccess (201 Created) {number} status Status Code
174
     * @apiSuccess (201 Created) {string} data Node ID if the last chunk was uploaded and a new node was added
175
     *
176
     * @apiSuccess (206 Partial Content) {number} status Status Code
177
     * @apiSuccess (206 Partial Content) {string} data Chunk ID if it was not the last chunk
178
     * @apiSuccessExample {json} Success-Response (Not the last chunk yet):
179
     * HTTP/1.1 206 Partial Content
180
     * {
181
     *      "session": "78297329329389e332234341",
182
     *      "size": 12323244224,
183
     *      "chunks_left": 4
184
     * }
185
     *
186
     * @apiSuccessExample {json} Success-Response (New file created, Last chunk):
187
     * HTTP/1.1 201 Created
188
     * {
189
     *      "id": "78297329329389e332234342",
190
     *      "version": 1
191
     * }
192
     *
193
     * @apiSuccessExample {json} Success-Response (File updated, Last chunk):
194
     * HTTP/1.1 200 OK
195
     * {
196
     *      "id": "78297329329389e332234342",
197
     *      "version": 2
198
     * }
199
     *
200
     * @apiErrorExample {json} Error-Response (quota full):
201
     * HTTP/1.1 507 Insufficient Storage
202
     * {
203
     *      "status": 507
204
     *      "data": {
205
     *          "error": "Balloon\Exception\InsufficientStorage",
206
     *          "message": "user quota is full",
207
     *          "code": 66
208
     *      }
209
     * }
210
     *
211
     * @apiErrorExample {json} Error-Response (Size limit exceeded):
212
     * HTTP/1.1 400 Bad Request
213
     * {
214
     *      "status": 400,
215
     *      "data": {
216
     *          "error": "Balloon\\Exception\\Conflict",
217
     *          "message": "file size exceeded limit",
218
     *          "code": 17
219
     *      }
220
     * }
221
     *
222
     * @apiErrorExample {json} Error-Response (Chunks lost):
223
     * HTTP/1.1 400 Bad Request
224
     * {
225
     *      "status": 400,
226
     *      "data": {
227
     *          "error": "Balloon\\Exception\\Conflict",
228
     *          "message": "chunks lost, reupload all chunks",
229
     *          "code": 275
230
     *      }
231
     * }
232
     *
233
     * @apiErrorExample {json} Error-Response (Chunks invalid size):
234
     * HTTP/1.1 400 Bad Request
235
     * {
236
     *      "status": 400,
237
     *      "data": {
238
     *          "error": "Balloon\\Exception\\Conflict",
239
     *          "message": "merged chunks temp file size is not as expected",
240
     *          "code": 276
241
     *      }
242
     * }
243
     *
244
     * @param string $id
245
     * @param string $p
246
     * @param string $collection
247
     * @param string $name
248
     * @param string $session
249
     * @param string $changed
250
     * @param string $created
251
     * @param bool   $readonly
252
     * @param array  $meta
253
     *
254
     * @return Response
255
     */
256
    public function putChunk(
257
        ?ObjectId $session = null,
258
        ?string $id = null,
259
        ?string $p = null,
260
        ?string $collection = null,
261
        ?string $name = null,
262
        int $index = 1,
263
        int $chunks = 0,
264
        int $size = 0,
0 ignored issues
show
The parameter $size is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
265
        int $conflict = 0,
266
        ?string $changed = null,
267
        ?string $created = null,
268
        ?bool $readonly = null,
269
        ?array $meta = null,
270
        ?array $acl = null
271
    ) {
272
        ini_set('auto_detect_line_endings', '1');
273
        $input = fopen('php://input', 'rb');
274
        if ($index > $chunks) {
275
            throw new Exception\InvalidArgument('chunk index can not be greater than the total number of chunks');
276
        }
277
278
        $storage = $this->getStorage($id, $p, $collection);
279
280
        if ($session === null) {
281
            $session = $storage->storeTemporaryFile($input, $this->server->getIdentity());
282
        } else {
283
            $storage->storeTemporaryFile($input, $this->server->getIdentity(), $session);
284
        }
285
286
        if ($index === $chunks) {
287
            $attributes = compact('changed', 'created', 'readonly', 'meta', 'acl');
288
            $attributes = array_filter($attributes, function ($attribute) {return !is_null($attribute); });
289
            $attributes = $this->_verifyAttributes($attributes);
290
291
            return $this->_put($session, $id, $p, $collection, $name, $attributes, $conflict);
292
        }
293
294
        return (new Response())->setCode(206)->setBody([
295
                'session' => (string) $session,
296
                'chunks_left' => $chunks - $index,
297
            ]);
298
    }
299
300
    /**
301
     * @api {put} /api/v2/files Upload file
302
     * @apiVersion 2.0.0
303
     * @apiName put
304
     * @apiGroup Node\File
305
     * @apiPermission none
306
     * @apiUse _getNode
307
     * @apiUse _conflictNode
308
     * @apiUse _writeAction
309
     *
310
     * @apiDescription Upload an entire file in one-shot. Attention, there is file size limit,
311
     * if you have possible big files use the method PUT chunk!
312
     *
313
     * @apiExample (cURL) example:
314
     * #Update content of file 544627ed3c58891f058b4686
315
     * curl -XPUT "https://SERVER/api/v2/files?id=544627ed3c58891f058b4686" --data-binary myfile.txt
316
     * curl -XPUT "https://SERVER/api/v2/files/544627ed3c58891f058b4686" --data-binary myfile.txt
317
     *
318
     * #Upload new file under collection 544627ed3c58891f058b3333
319
     * curl -XPUT "https://SERVER/api/v2/files?collection=544627ed3c58891f058b3333&name=myfile.txt" --data-binary myfile.txt
320
     *
321
     * @apiParam (GET Parameter) {string} [id] Either id, p (path) of a file node or a parent collection id must be given
322
     *
323
     * @apiParam (GET Parameter) {string} [id] Either id, p (path) of a file node or a parent collection id must be given
324
     * @apiParam (GET Parameter) {string} [p] Either id, p (path) of a file node or a parent collection id must be given
325
     * @apiParam (GET Parameter) {string} [collection] Either id, p (path) of a file node or a parent collection id must be given
326
     * (If none of them are given, the file will be placed to the root)
327
     * @apiParam (GET Parameter) {string} [name] Needs to be set if the chunk belongs to a new file
328
     * or to identify an existing child file if a collection id was set
329
     * @apiParam (GET Parameter) {string} [created] Set specific created ISO806 timestamp
330
     * @apiParam (GET Parameter) {string} [changed] Set specific changed ISO806 timestamp
331
     * @apiParam (GET Parameter) {bool} [readonly] Mark node readonly
332
     * @apiParam (GET Parameter) {object} [meta] Meta attributes
333
     *
334
     * @apiSuccess (200 OK) {number} status Status Code
335
     * @apiSuccess (200 OK) {number} data Increased version number if an existing file was updated. It will return
336
     * the old version if the submited file content was equal to the existing one.
337
     *
338
     * @apiSuccess (201 Created) {number} status Status Code
339
     * @apiSuccess (201 Created) {string} data Node ID
340
     * @apiSuccessExample {json} Success-Response (New file created):
341
     * HTTP/1.1 201 Created
342
     * {
343
     *      "status": 201,
344
     *      "data": "78297329329389e332234342"
345
     * }
346
     *
347
     * @apiSuccessExample {json} Success-Response (File updated):
348
     * HTTP/1.1 200 OK
349
     * {
350
     *      "status": 200,
351
     *      "data": 2
352
     * }
353
     *
354
     * @apiErrorExample {json} Error-Response (quota full):
355
     * HTTP/1.1 507 Insufficient Storage
356
     * {
357
     *      "status": 507
358
     *      "data": {
359
     *          "error": "Balloon\Exception\InsufficientStorage",
360
     *          "message": "user quota is full",
361
     *          "code": 65
362
     *      }
363
     * }
364
     *
365
     * @apiErrorExample {json} Error-Response (Size limit exceeded):
366
     * HTTP/1.1 400 Bad Request
367
     * {
368
     *      "status": 400,
369
     *      "data": {
370
     *          "error": "Balloon\\Exception\\Conflict",
371
     *          "message": "file size exceeded limit",
372
     *          "code": 17
373
     *      }
374
     * }
375
     *
376
     * @param string $id
377
     * @param string $p
378
     * @param string $collection
379
     * @param string $name
380
     * @param string $changed
381
     * @param string $created
382
     * @param bool   $readonly
383
     * @param array  $meta
384
     */
385
    public function put(
386
        ?string $id = null,
387
        ?string $p = null,
388
        ?string $collection = null,
389
        ?string $name = null,
390
        int $conflict = 0,
391
        ?string $changed = null,
392
        ?string $created = null,
393
        ?bool $readonly = null,
394
        ?array $meta = null,
395
        ?array $acl = null
396
    ): Response {
397
        ini_set('auto_detect_line_endings', '1');
398
        $input = fopen('php://input', 'rb');
399
400
        $storage = $this->getStorage($id, $p, $collection);
401
        $session = $storage->storeTemporaryFile($input, $this->server->getIdentity());
402
        $attributes = compact('changed', 'created', 'readonly', 'meta', 'acl');
403
        $attributes = array_filter($attributes, function ($attribute) {return !is_null($attribute); });
404
        $attributes = $this->_verifyAttributes($attributes);
405
406
        return $this->_put($session, $id, $p, $collection, $name, $attributes, $conflict);
407
    }
408
409
    /**
410
     * Get storage.
411
     */
412
    protected function getStorage($id, $p, $collection): StorageAdapterInterface
413
    {
414
        if ($id !== null) {
415
            return $this->_getNode($id, $p)->getParent()->getStorage();
416
        }
417
        if ($p !== null) {
418
            $path = '/'.ltrim(dirname('/'.$p), '/');
419
420
            return $this->_getNode($id, $path, Collection::class)->getStorage();
421
        }
422
        if ($id === null && $p === null && $collection === null) {
423
            return $this->server->getFilesystem()->getRoot()->getStorage();
424
        }
425
426
        return $this->_getNode($collection, null, Collection::class)->getStorage();
427
    }
428
429
    /**
430
     * Add or update file.
431
     *
432
     * @param string $id
433
     * @param string $p
434
     * @param string $collection
435
     * @param string $name
436
     */
437
    protected function _put(
438
        ObjectId $session,
439
        ?string $id = null,
440
        ?string $p = null,
441
        ?string $collection = null,
442
        ?string $name = null,
443
        array $attributes = [],
444
        int $conflict = 0
0 ignored issues
show
The parameter $conflict is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
445
    ): Response {
446
        if (null === $id && null === $p && null === $name) {
447
            throw new Exception\InvalidArgument('neither id, p nor name was set');
448
        }
449
450
        if (null !== $p && null !== $name) {
451
            throw new Exception\InvalidArgument('p and name can not be used at the same time');
452
        }
453
454
        try {
455
            if (null !== $p) {
456
                $node = $this->_getNode(null, $p);
457
                $node->setContent($session, $attributes);
458
                $result = $this->node_decorator->decorate($node);
459
460
                return (new Response())->setCode(200)->setBody($result);
461
            }
462
            if (null !== $id && null === $collection) {
463
                $node = $this->_getNode($id);
464
                $node->setContent($session, $attributes);
465
                $result = $this->node_decorator->decorate($node);
466
467
                return (new Response())->setCode(200)->setBody($result);
468
            }
469
            if (null === $p && null === $id && null !== $name) {
470
                $collection = $this->_getNode($collection, null, Collection::class, false, true);
471
472
                if ($collection->childExists($name, NodeInterface::DELETED_INCLUDE, ['directory' => false])) {
473
                    $child = $collection->getChild($name, NodeInterface::DELETED_INCLUDE, ['directory' => false]);
474
                    $child->setContent($session, $attributes);
475
                    $result = $this->node_decorator->decorate($child);
476
477
                    return (new Response())->setCode(200)->setBody($result);
478
                }
479
                if (!is_string($name) || empty($name)) {
480
                    throw new Exception\InvalidArgument('name must be a valid string');
481
                }
482
483
                $result = $collection->addFile($name, $session, $attributes);
484
                $result = $this->node_decorator->decorate($result);
485
486
                return (new Response())->setCode(201)->setBody($result);
487
            }
488
        } catch (ForbiddenException $e) {
489
            throw new Exception\Conflict(
490
                'a node called '.$name.' does already exists in this collection',
491
                Exception\Conflict::NODE_WITH_SAME_NAME_ALREADY_EXISTS,
492
                $e
493
            );
494
        } catch (Exception\NotFound $e) {
495
            if (null !== $p && null === $id) {
496
                if (!is_string($p) || empty($p)) {
497
                    throw new Exception\InvalidArgument('path (p) must be a valid string');
498
                }
499
500
                $parent_path = '/'.ltrim(dirname('/'.$p), '/');
501
                $name = basename($p);
502
503
                try {
504
                    $parent = $this->fs->findNodeByPath($parent_path, Collection::class);
505
506
                    if (!is_string($name) || empty($name)) {
507
                        throw new Exception\InvalidArgument('name must be a valid string');
508
                    }
509
510
                    $result = $parent->addFile($name, $session, $attributes);
511
                    $result = $this->node_decorator->decorate($result);
512
513
                    return (new Response())->setCode(201)->setBody($result);
514
                } catch (Exception\NotFound $e) {
515
                    throw new Exception('parent collection '.$parent_path.' was not found');
516
                }
517
            } else {
518
                throw $e;
519
            }
520
        }
521
    }
522
}
523