Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.49% covered (success)
94.49%
240 / 254
82.61% covered (warning)
82.61%
19 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nodes
94.49% covered (success)
94.49%
240 / 254
82.61% covered (warning)
82.61%
19 / 23
83.13
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
 list
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 query
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 create
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 put
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 patch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 listing
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
9
 sync
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
6
 narrow
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 guarded
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
11.28
 passes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 readable
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 assertWritable
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 allow
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 preconditions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 body
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 written
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 actor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 user
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api\Controller;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Core\Exception\HttpForbidden;
10use Celema\Core\Exception\HttpNotFound;
11use Celema\Core\Exception\HttpUnauthorized;
12use Celema\Core\Request;
13use Closure;
14use Cosray\Actor;
15use Cosray\Api\Exception\PreconditionFailed;
16use Cosray\Api\Exception\UnsupportedMediaType;
17use Cosray\Api\NodeQuery;
18use Cosray\Api\PreviewToken;
19use Cosray\Api\Responses;
20use Cosray\Api\Serializer;
21use Cosray\Api\SyncCursor;
22use Cosray\Api\Writer;
23use Cosray\Cms;
24use Cosray\Config;
25use Cosray\Exception\ParserException;
26use Cosray\Finder\Nodes as NodeFinder;
27use Cosray\Finder\Order;
28use Cosray\Node\Factory;
29use Cosray\Node\Wrapper;
30use Cosray\Security\NodeAccess;
31use Cosray\User;
32use JsonException;
33use PDOException;
34use Psr\Http\Message\ResponseInterface as Response;
35
36final class Nodes
37{
38    public function __construct(
39        private readonly Responses $responses,
40        private readonly NodeAccess $access,
41        private readonly Serializer $serializer,
42    ) {}
43
44    public function list(Request $request, Cms $cms): Response
45    {
46        return $this->listing($request, $cms, NodeQuery::fromQuery($request->params()));
47    }
48
49    /**
50     * Full-text search over what the website lists: `q` takes the words,
51     * every listing parameter narrows the result, and relevance orders it
52     * unless `order` names another order.
53     */
54    public function search(Request $request, Cms $cms): Response
55    {
56        $params = $request->params();
57        $q = $params['q'] ?? null;
58
59        if (!is_string($q) || trim($q) === '') {
60            throw new HttpBadRequest($request, payload: ['message' => '`q` names the words to search for.']);
61        }
62
63        unset($params['q']);
64
65        return $this->listing($request, $cms, NodeQuery::fromQuery($params), trim($q));
66    }
67
68    /** The listing with its parameters in a JSON body, for uid lists and filters too long for a URL. */
69    public function query(Request $request, Cms $cms): Response
70    {
71        return $this->listing($request, $cms, NodeQuery::fromBody($this->body($request, ['application/json'])));
72    }
73
74    /**
75     * `preview` takes a token the panel issued for this node and answers
76     * with its working copy, published or not.
77     */
78    public function get(Request $request, Cms $cms, Config $config, string $uid): Response
79    {
80        $params = $request->params();
81        $preview = $params['preview'] ?? null;
82
83        if ($preview !== null) {
84            $tokens = PreviewToken::fromConfig($config);
85
86            if ($tokens === null || !is_string($preview) || $tokens->uid($preview) !== $uid) {
87                throw new HttpUnauthorized($request, payload: [
88                    'message' => 'The preview token is invalid or expired.',
89                ]);
90            }
91
92            $node = $cms->node->working($uid)
93                ?? throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]);
94        } else {
95            $deleted = NodeQuery::state($params['deleted'] ?? null, 'deleted', false);
96            $node = $this->readable($request, $cms, $uid, $deleted);
97        }
98
99        return $this->responses->json($this->serializer->node(
100            $node,
101            NodeQuery::source($params['view'] ?? null),
102            NodeQuery::fields($params['fields'] ?? null),
103        ), headers: ['ETag' => Serializer::etag($node)]);
104    }
105
106    /** Creates a node; the body names its type and may choose its uid. */
107    public function create(Request $request, Cms $cms, Writer $writer): Response
108    {
109        $body = $this->body($request, ['application/json']);
110        $class = $this->type($request, $body['type'] ?? null);
111        $uid = $body['uid'] ?? null;
112
113        if ($uid !== null && (!is_string($uid) || preg_match('/^[A-Za-z0-9-_.]{1,64}$/', $uid) !== 1)) {
114            throw new HttpBadRequest($request, payload: [
115                'message' => '`uid` is up to 64 letters, digits, dashes, underscores and dots.',
116            ]);
117        }
118
119        $this->allow($request, NodeAccess::CREATE, $class);
120        $uid = $writer->create($class, $body, $this->actor($request), $uid);
121
122        return $this->written($request, $cms, $uid, 201);
123    }
124
125    /**
126     * Creates the node under this uid or replaces its content. `If-Match`
127     * guards a replacement, `If-None-Match: *` makes the request create only.
128     */
129    public function put(Request $request, Cms $cms, Writer $writer, string $uid): Response
130    {
131        $body = $this->body($request, ['application/json']);
132
133        if (array_key_exists('uid', $body) && $body['uid'] !== $uid) {
134            throw new HttpBadRequest($request, payload: [
135                'message' => 'The uid in the body differs from the uid in the URL.',
136            ]);
137        }
138
139        $status = $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $body): int {
140            $node = $cms->node->get(['uid' => $uid, 'published' => null]);
141            $this->preconditions($request, $node);
142
143            if ($node === null) {
144                $class = $this->type($request, $body['type'] ?? null);
145                $this->allow($request, NodeAccess::CREATE, $class);
146                $writer->create($class, $body, $this->actor($request), $uid);
147
148                return 201;
149            }
150
151            $this->assertWritable($request, $node);
152            $writer->replace($node, $body, $this->actor($request));
153
154            return 200;
155        });
156
157        return $this->written($request, $cms, $uid, $status);
158    }
159
160    /** Applies a JSON Merge Patch to the node and to an editor's working copy of it. */
161    public function patch(Request $request, Cms $cms, Writer $writer, string $uid): Response
162    {
163        $patch = $this->body($request, ['application/merge-patch+json', 'application/json']);
164        $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $patch): void {
165            $node = $this->readable($request, $cms, $uid, false);
166            $this->preconditions($request, $node);
167            $this->assertWritable($request, $node);
168            $writer->patch($node, $patch, $this->actor($request));
169        });
170
171        return $this->written($request, $cms, $uid, 200);
172    }
173
174    /** Soft-deletes the node; `?children=true` takes its descendants along. */
175    public function delete(Request $request, Cms $cms, Writer $writer, string $uid): Response
176    {
177        $children = NodeQuery::state($request->params()['children'] ?? null, 'children', false) === true;
178        $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $children): void {
179            $node = $this->readable($request, $cms, $uid, false);
180            $this->preconditions($request, $node);
181            $this->allow($request, NodeAccess::DELETE, Wrapper::unwrap($node)::class);
182            $writer->delete($node, $this->actor($request), $children);
183        });
184
185        return $this->responses->empty();
186    }
187
188    private function listing(Request $request, Cms $cms, NodeQuery $query, ?string $fulltext = null): Response
189    {
190        $unknown = array_diff($query->types, array_keys($this->access->registered()));
191
192        if ($unknown !== []) {
193            throw new HttpBadRequest($request, payload: [
194                'message' => 'Unknown type: ' . implode(', ', $unknown) . '.',
195            ]);
196        }
197
198        if ($query->sync !== null) {
199            if ($fulltext !== null) {
200                throw new HttpBadRequest($request, payload: [
201                    'message' => 'A search does not sync; list the nodes with `changedSince` instead.',
202                ]);
203            }
204
205            return $this->sync($request, $cms, $query, $query->sync);
206        }
207
208        $readable = $this->access->readable(self::user($request));
209        $nodes = $cms
210            ->nodes()
211            ->published($query->published)
212            ->hidden($query->hidden)
213            ->deleted($query->deleted)
214            ->readable($readable['live'], $readable['any']);
215
216        if ($fulltext !== null) {
217            $nodes->fulltext($fulltext);
218        }
219
220        [$items, $total] = $this->guarded($request, function () use ($nodes, $query, $fulltext): array {
221            $this->narrow($nodes, $query);
222            $total = $nodes->count();
223
224            if ($fulltext === null || $query->ordered) {
225                $nodes->order(...$query->order);
226            }
227
228            $nodes->limit($query->limit)->offset($query->offset);
229            $items = [];
230
231            foreach ($nodes as $node) {
232                $item = $this->serializer->node($node, $query->source, $query->fields);
233                $search = $node->meta->search;
234
235                if ($search !== null) {
236                    $item['search'] = ['score' => $search->score, 'snippet' => (string) $search->snippet->html()];
237                }
238
239                $items[] = $item;
240            }
241
242            return [$items, $total];
243        });
244
245        return $this->responses->json([
246            'items' => $items,
247            'total' => $total,
248            'limit' => $query->limit,
249            'offset' => $query->offset,
250        ]);
251    }
252
253    /**
254     * The next changes after the cursor, in change order: nodes that pass the
255     * state filters come as items, the others, which the client should drop,
256     * under `removed` with only their uid, type and change time. `cursor`
257     * goes on after the last change given, `more` tells whether others
258     * follow right away.
259     */
260    private function sync(Request $request, Cms $cms, NodeQuery $query, SyncCursor $cursor): Response
261    {
262        $user = self::user($request);
263        $readable = $this->access->readable($user);
264        $nodes = $cms
265            ->nodes()
266            ->published(null)
267            ->hidden(null)
268            ->deleted(null)
269            ->readable([], [...$readable['live'], ...$readable['any']]);
270
271        [$items, $removed, $last, $more] = $this->guarded($request, function () use (
272            $nodes,
273            $query,
274            $cursor,
275            $user,
276        ): array {
277            $this->narrow($nodes, $query);
278            $nodes
279                ->match($cursor->filter())
280                ->order(new Order('changed'), new Order('uid'))
281                ->limit($query->limit + 1);
282            $items = [];
283            $removed = [];
284            $last = null;
285
286            foreach ($nodes as $node) {
287                if ((count($items) + count($removed)) === $query->limit) {
288                    return [$items, $removed, $last, true];
289                }
290
291                $last = Wrapper::unwrap($node);
292
293                if (self::passes($last, $query) && $this->access->reads($user, $node)) {
294                    $items[] = $this->serializer->node($node, $query->source, $query->fields);
295
296                    continue;
297                }
298
299                $item = $this->serializer->node($node, fields: []);
300                $removed[] = ['uid' => $item['uid'], 'type' => $item['type'], 'changed' => $item['changed']];
301            }
302
303            return [$items, $removed, $last, false];
304        });
305        $next = $last === null
306            ? $cursor
307            : SyncCursor::after((string) Factory::meta($last, 'changed'), (string) Factory::meta($last, 'uid'));
308
309        return $this->responses->json([
310            'items' => $items,
311            'removed' => $removed,
312            'limit' => $query->limit,
313            'cursor' => $next->token(),
314            'more' => $more,
315        ]);
316    }
317
318    /** The query's conditions besides the states, which listing and sync treat apart. */
319    private function narrow(NodeFinder $nodes, NodeQuery $query): void
320    {
321        if ($query->types !== []) {
322            $nodes->types(...$query->types);
323        }
324
325        if ($query->uids !== []) {
326            $nodes->only(...$query->uids);
327        }
328
329        if ($query->handles !== []) {
330            $nodes->match(['handle' => ['in' => $query->handles]]);
331        }
332
333        if ($query->parent !== null) {
334            $nodes->childrenOf($query->parent);
335        }
336
337        if ($query->filter !== null) {
338            $nodes->match($query->filter, NodeQuery::BUILTINS);
339        }
340    }
341
342    /**
343     * Runs the query, answering a filter or order the stored values do not
344     * fit with 400.
345     *
346     * @template T
347     *
348     * @param Closure(): T $query
349     * @return T
350     */
351    private function guarded(Request $request, Closure $query): mixed
352    {
353        try {
354            return $query();
355        } catch (ParserException $e) {
356            throw new HttpBadRequest($request, payload: ['message' => $e->getMessage()], previous: $e);
357        } catch (PDOException $e) {
358            // Class 22, data exceptions: a typed order or filter met values of another type.
359            if (!str_starts_with((string) $e->getCode(), '22')) {
360                throw $e;
361            }
362
363            throw new HttpBadRequest(
364                $request,
365                payload: [
366                    'message' => 'The filter or order does not fit the stored values.',
367                ],
368                previous: $e,
369            );
370        }
371    }
372
373    /** Whether the node is in the states the query asks for. */
374    private static function passes(object $node, NodeQuery $query): bool
375    {
376        $states = [
377            [$query->published, (bool) Factory::meta($node, 'published')],
378            [$query->hidden, (bool) Factory::meta($node, 'hidden')],
379            [$query->deleted, Factory::meta($node, 'deleted') !== null],
380        ];
381
382        foreach ($states as [$wanted, $actual]) {
383            if ($wanted !== null && $wanted !== $actual) {
384                return false;
385            }
386        }
387
388        return true;
389    }
390
391    /** A node the client may read; any other answers 404, as if it did not exist. */
392    private function readable(Request $request, Cms $cms, string $uid, ?bool $deleted): object
393    {
394        $node = $cms->node->get(array_filter(
395            [
396                'uid' => $uid,
397                'published' => null,
398                'deleted' => $deleted,
399            ],
400            static fn(mixed $value): bool => $value !== null,
401        ));
402
403        if ($node === null || !$this->access->reads(self::user($request), $node)) {
404            throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]);
405        }
406
407        return $node;
408    }
409
410    private function assertWritable(Request $request, object $node): void
411    {
412        $inner = Wrapper::unwrap($node);
413
414        if (!$this->access->reads(self::user($request), $node)) {
415            throw new HttpNotFound($request, payload: ['message' => 'There is no such node.']);
416        }
417
418        if (Factory::meta($inner, 'deleted') !== null) {
419            throw new HttpConflict($request, payload: ['message' => 'The node is deleted.']);
420        }
421
422        $this->allow($request, NodeAccess::CHANGE, $inner::class);
423
424        if ((bool) Factory::meta($inner, 'locked')) {
425            throw new HttpConflict($request, payload: ['message' => 'The node is locked.']);
426        }
427    }
428
429    /** @param class-string $class */
430    private function allow(Request $request, string $action, string $class): void
431    {
432        if (!$this->access->allows(self::user($request), $action, $class)) {
433            throw new HttpForbidden($request, payload: [
434                'message' => "The token may not {$action} nodes of this type.",
435            ]);
436        }
437    }
438
439    /** @return class-string */
440    private function type(Request $request, mixed $type): string
441    {
442        $registered = $this->access->registered();
443
444        if (!is_string($type) || !isset($registered[$type])) {
445            throw new HttpBadRequest($request, payload: ['message' => 'Name a known node `type`.']);
446        }
447
448        return $registered[$type];
449    }
450
451    private function preconditions(Request $request, ?object $node): void
452    {
453        $psr = $request->unwrap();
454        $ifMatch = trim($psr->getHeaderLine('If-Match'));
455        $ifNoneMatch = trim($psr->getHeaderLine('If-None-Match'));
456
457        if ($ifNoneMatch === '*' && $node !== null) {
458            throw new PreconditionFailed($request, payload: ['message' => 'The node exists.']);
459        }
460
461        if ($ifMatch === '') {
462            return;
463        }
464
465        if ($node === null) {
466            throw new PreconditionFailed($request, payload: ['message' => 'The node does not exist.']);
467        }
468
469        $tags = array_map(trim(...), explode(',', $ifMatch));
470
471        if ($ifMatch !== '*' && !in_array(Serializer::etag($node), $tags, true)) {
472            throw new PreconditionFailed($request, payload: ['message' => 'The node changed since it was read.']);
473        }
474    }
475
476    /**
477     * @param list<string> $types
478     * @return array<array-key, mixed>
479     */
480    private function body(Request $request, array $types): array
481    {
482        $type = strtolower(trim(explode(';', $request->unwrap()->getHeaderLine('Content-Type'))[0]));
483
484        if (!in_array($type, $types, true)) {
485            throw new UnsupportedMediaType($request, payload: ['message' => 'Send ' . implode(' or ', $types) . '.']);
486        }
487
488        try {
489            $body = $request->json(JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR);
490        } catch (JsonException) {
491            throw new HttpBadRequest($request, payload: ['message' => 'The body is not valid JSON.']);
492        }
493
494        if (!is_array($body) || $body !== [] && array_is_list($body)) {
495            throw new HttpBadRequest($request, payload: ['message' => 'The body is a JSON object.']);
496        }
497
498        return $body;
499    }
500
501    /** The node after a write, in the view the request asks for. */
502    private function written(Request $request, Cms $cms, string $uid, int $status): Response
503    {
504        $node = $cms->node->get(['uid' => $uid, 'published' => null])
505            ?? throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]);
506        $params = $request->params();
507        $headers = ['ETag' => Serializer::etag($node)];
508
509        if ($status === 201) {
510            $headers['Location'] =
511                rtrim($request->uri()->getPath(), '/') . ($request->method() === 'POST' ? "/{$uid}" : '');
512        }
513
514        return $this->responses->json(
515            $this->serializer->node(
516                $node,
517                NodeQuery::source($params['view'] ?? null),
518                NodeQuery::fields($params['fields'] ?? null),
519            ),
520            $status,
521            $headers,
522        );
523    }
524
525    private function actor(Request $request): Actor
526    {
527        $user = self::user($request);
528
529        return $user === null ? Actor::system() : new Actor($user->id);
530    }
531
532    private static function user(Request $request): ?User
533    {
534        $user = $request->get('user', null);
535
536        return $user instanceof User ? $user : null;
537    }
538}