Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.52% covered (success)
95.52%
64 / 67
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Assets
95.52% covered (success)
95.52%
64 / 67
50.00% covered (danger)
50.00%
3 / 6
24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 list
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 upload
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
10
 type
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 permit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api\Controller;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpForbidden;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Core\Request;
11use Celema\Quma\Database;
12use Cosray\Actor;
13use Cosray\Api\NodeQuery;
14use Cosray\Api\Responses;
15use Cosray\Api\Serializer;
16use Cosray\Assets\Ingest;
17use Cosray\Assets\Library;
18use Cosray\Assets\Meta;
19use Cosray\Config;
20use Cosray\Context;
21use Cosray\Exception\IngestError;
22use Cosray\Security\Policy;
23use Cosray\User;
24use finfo;
25use Psr\Http\Message\ResponseInterface as Response;
26use 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 */
32final 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}