Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.52% |
64 / 67 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
| Assets | |
95.52% |
64 / 67 |
|
50.00% |
3 / 6 |
24 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| list | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
5 | |||
| upload | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
10 | |||
| type | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| permit | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Api\Controller; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpBadRequest; |
| 8 | use Celema\Core\Exception\HttpForbidden; |
| 9 | use Celema\Core\Exception\HttpNotFound; |
| 10 | use Celema\Core\Request; |
| 11 | use Celema\Quma\Database; |
| 12 | use Cosray\Actor; |
| 13 | use Cosray\Api\NodeQuery; |
| 14 | use Cosray\Api\Responses; |
| 15 | use Cosray\Api\Serializer; |
| 16 | use Cosray\Assets\Ingest; |
| 17 | use Cosray\Assets\Library; |
| 18 | use Cosray\Assets\Meta; |
| 19 | use Cosray\Config; |
| 20 | use Cosray\Context; |
| 21 | use Cosray\Exception\IngestError; |
| 22 | use Cosray\Security\Policy; |
| 23 | use Cosray\User; |
| 24 | use finfo; |
| 25 | use Psr\Http\Message\ResponseInterface as Response; |
| 26 | use Psr\Http\Message\UploadedFileInterface; |
| 27 | |
| 28 | /** |
| 29 | * The asset catalog. Anyone reads an asset by uid, as its file is public |
| 30 | * anyway; listing the catalog takes `read-nodes`, uploading `edit-nodes`. |
| 31 | */ |
| 32 | final class Assets |
| 33 | { |
| 34 | public function __construct( |
| 35 | private readonly Responses $responses, |
| 36 | private readonly Serializer $serializer, |
| 37 | private readonly Policy $policy, |
| 38 | ) {} |
| 39 | |
| 40 | public function get(Request $request, Context $context, string $uid): Response |
| 41 | { |
| 42 | $asset = $context->assets()->get($uid) |
| 43 | ?? throw new HttpNotFound($request, payload: ['message' => "There is no asset '{$uid}'."]); |
| 44 | |
| 45 | return $this->responses->json($this->serializer->asset($asset)); |
| 46 | } |
| 47 | |
| 48 | /** Newest first; `kind` takes image, video, audio and document, `q` matches the filename. */ |
| 49 | public function list(Request $request, Library $library): Response |
| 50 | { |
| 51 | $this->permit($request, 'read-nodes', 'list the catalog'); |
| 52 | $params = $request->params(); |
| 53 | $kinds = $params['kind'] ?? []; |
| 54 | $q = $params['q'] ?? ''; |
| 55 | $query = NodeQuery::fromQuery(array_intersect_key($params, ['limit' => true, 'offset' => true])); |
| 56 | |
| 57 | if (!is_string($kinds) && !is_array($kinds) || !is_string($q)) { |
| 58 | throw new HttpBadRequest($request, payload: ['message' => '`kind` and `q` are strings.']); |
| 59 | } |
| 60 | |
| 61 | $list = $library->list( |
| 62 | array_values(array_filter(is_string($kinds) ? explode(',', $kinds) : $kinds, is_string(...))), |
| 63 | $q, |
| 64 | Library::since($params['since'] ?? null), |
| 65 | $query->limit, |
| 66 | $query->offset, |
| 67 | ); |
| 68 | |
| 69 | return $this->responses->json([ |
| 70 | 'items' => array_map($this->serializer->asset(...), $list['assets']), |
| 71 | 'total' => $list['total'], |
| 72 | 'limit' => $query->limit, |
| 73 | 'offset' => $query->offset, |
| 74 | ]); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Takes a multipart `file`, an optional `type` (image, video or file, |
| 79 | * otherwise read off the file) and optional `meta` JSON with `alt`, |
| 80 | * `title` and `caption` maps and a `credit`. Identical bytes answer with |
| 81 | * the asset already in the catalog. |
| 82 | */ |
| 83 | public function upload(Request $request, Context $context, Config $config, Database $db): Response |
| 84 | { |
| 85 | $user = $this->permit($request, 'edit-nodes', 'upload'); |
| 86 | $file = $request->unwrap()->getUploadedFiles()['file'] ?? null; |
| 87 | |
| 88 | if (!$file instanceof UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { |
| 89 | throw new HttpBadRequest($request, payload: ['message' => 'Send the file as the multipart field `file`.']); |
| 90 | } |
| 91 | |
| 92 | $contents = (string) $file->getStream(); |
| 93 | $fields = $request->unwrap()->getParsedBody(); |
| 94 | $fields = is_array($fields) ? $fields : []; |
| 95 | $type = $fields['type'] ?? $this->type($config, $contents); |
| 96 | |
| 97 | if (!in_array($type, ['image', 'video', 'file'], true)) { |
| 98 | throw new HttpBadRequest($request, payload: ['message' => '`type` is image, video or file.']); |
| 99 | } |
| 100 | |
| 101 | $meta = json_decode(is_string($fields['meta'] ?? null) ? $fields['meta'] : '{}', true); |
| 102 | $localeIds = array_keys(iterator_to_array($context->locales())); |
| 103 | |
| 104 | try { |
| 105 | $result = new Ingest($config, $db)->ingest( |
| 106 | $contents, |
| 107 | (string) $file->getClientFilename(), |
| 108 | $type, |
| 109 | $user === null ? Actor::system() : new Actor($user->id), |
| 110 | Meta::apply([], is_array($meta) ? $meta : [], $localeIds, $type === 'image'), |
| 111 | ); |
| 112 | } catch (IngestError $e) { |
| 113 | throw new HttpBadRequest( |
| 114 | $request, |
| 115 | payload: [ |
| 116 | 'message' => $e->userMessage, |
| 117 | 'errors' => [new \Celema\Sire\Issue(['file'], 'rejected', $e->userMessage)], |
| 118 | ], |
| 119 | previous: $e, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | $uid = (string) $result->row['uid']; |
| 124 | $asset = $context->assets()->get($uid) |
| 125 | ?? throw new HttpNotFound($request, payload: ['message' => "There is no asset '{$uid}'."]); |
| 126 | |
| 127 | return $this->responses->json($this->serializer->asset($asset), $result->created ? 201 : 200, [ |
| 128 | 'Location' => rtrim($request->uri()->getPath(), '/') . "/{$uid}", |
| 129 | ]); |
| 130 | } |
| 131 | |
| 132 | private function type(Config $config, string $contents): string |
| 133 | { |
| 134 | $mime = (string) new finfo(FILEINFO_MIME_TYPE)->buffer($contents); |
| 135 | |
| 136 | return match (true) { |
| 137 | isset($config->upload->image[$mime]) => 'image', |
| 138 | isset($config->upload->video[$mime]) => 'video', |
| 139 | default => 'file', |
| 140 | }; |
| 141 | } |
| 142 | |
| 143 | private function permit(Request $request, string $permission, string $action): ?User |
| 144 | { |
| 145 | $user = $request->get('user', null); |
| 146 | $user = $user instanceof User ? $user : null; |
| 147 | |
| 148 | if (!$this->policy->permits($user, $permission)) { |
| 149 | throw new HttpForbidden($request, payload: ['message' => "The token may not {$action}."]); |
| 150 | } |
| 151 | |
| 152 | return $user; |
| 153 | } |
| 154 | } |