Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.57% covered (success)
98.57%
69 / 70
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Node
98.57% covered (success)
98.57%
69 / 70
83.33% covered (warning)
83.33%
5 / 6
33
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
 catchall
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 preview
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 dispatch
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
13
 jsonRead
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 redirectToActivePath
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Factory\Factory;
10use Celema\Core\Response;
11use Cosray\Api\Responses;
12use Cosray\Api\Serializer;
13use Cosray\Cms;
14use Cosray\Context;
15use Cosray\Contract\HttpDelete;
16use Cosray\Contract\HttpGet;
17use Cosray\Contract\HttpPost;
18use Cosray\Contract\HttpPut;
19use Cosray\Contract\Json;
20use Cosray\Exception\RuntimeException;
21use Cosray\Middleware\Permission;
22use Cosray\Node\Types;
23use Cosray\Node\View;
24use Cosray\Node\Wrapper;
25use Cosray\Security\NodeAccess;
26use Cosray\User;
27use Cosray\Util\Path;
28
29class Node
30{
31    public function __construct(
32        protected readonly Factory $factory,
33        protected readonly Types $types,
34    ) {}
35
36    public function catchall(Context $context, Cms $cms, NodeAccess $access): Response
37    {
38        $request = $context->httpRequest();
39        $config = $context->config;
40        $path = $request->uri()->getPath();
41        $prefix = $config->app->urlPrefix;
42
43        if ($prefix) {
44            $path = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $path) ?? $path;
45        }
46
47        $node = $cms->node->byPath($path === '' ? '/' : $path);
48        $user = $request->get('user', null);
49
50        if ($node !== null && !$access->serves($user instanceof User ? $user : null, $node)) {
51            throw new HttpNotFound($request);
52        }
53
54        if (!$node) {
55            try {
56                $path = Path::inside($config->path->public, $path, checkIsFile: true);
57
58                return Response::create($this->factory)->file($path);
59            } catch (RuntimeException $e) {
60                return (
61                    $this->redirectToActivePath($context, $path)
62                        ?? throw new HttpNotFound($request, previous: $e)
63                );
64            }
65        }
66
67        return $this->dispatch($node, $context, $cms);
68    }
69
70    /** The working copy of a node, published or not, rendered as the public path would. */
71    #[Permission('panel')]
72    public function preview(Context $context, Cms $cms, string $uid): Response
73    {
74        $node = $cms->node->working($uid);
75
76        if (!$node || !(bool) $this->types->get(Wrapper::unwrap($node)::class, 'renderable', false)) {
77            throw new HttpNotFound($context->httpRequest());
78        }
79
80        // Preview goes through the same dispatch as the public path, so a
81        // node that answers GET itself is previewed the way it is served.
82        return $this->dispatch($node, $context, $cms);
83    }
84
85    private function dispatch(object $node, Context $context, Cms $cms): Response
86    {
87        $request = $context->httpRequest();
88        $method = $request->method();
89        $inner = Wrapper::unwrap($node);
90        // A node implementing the request method's interface takes over that request completely.
91        $response = match (true) {
92            $method === 'GET' && $inner instanceof HttpGet => $inner->httpGet(),
93            $method === 'POST' && $inner instanceof HttpPost => $inner->httpPost(),
94            $method === 'PUT' && $inner instanceof HttpPut => $inner->httpPut(),
95            $method === 'DELETE' && $inner instanceof HttpDelete => $inner->httpDelete(),
96            default => null,
97        };
98
99        if ($response !== null) {
100            return $response;
101        }
102
103        if ($method !== 'GET') {
104            throw new HttpBadRequest();
105        }
106
107        if ($request->get('isXhr', false)) {
108            return $this->jsonRead($node, $context);
109        }
110
111        return new View($inner, $cms, $context, $this->types)->render();
112    }
113
114    private function jsonRead(object $node, Context $context): Response
115    {
116        $inner = Wrapper::unwrap($node);
117        $serializer = new Serializer($context, $this->types);
118        $data = $inner instanceof Json ? $inner->json($serializer) : $serializer->node($node);
119        $content = Responses::encode($data);
120
121        return new Response(
122            $this->factory
123                ->response()
124                ->withHeader('Content-Type', 'application/json'),
125        )->body($content);
126    }
127
128    /**
129     * A permanent redirect from a path the node no longer uses to its active
130     * path in the current locale or the nearest fallback locale.
131     */
132    protected function redirectToActivePath(Context $context, string $path): ?Response
133    {
134        $db = $context->db;
135        $path = $db->paths->byPath(['path' => $path])->first();
136
137        if ($path && !($path['inactive'] === null)) {
138            $paths = $db->paths->activeByNode(['node' => $path['node']])->all();
139
140            $pathsByLocale = array_combine(
141                array_map(static fn($p) => $p['locale'], $paths),
142                array_map(static fn($p) => $p['path'], $paths),
143            );
144
145            $query = $context->httpRequest()->uri()->getQuery();
146            $locale = $context->locale();
147
148            while ($locale) {
149                $path = $pathsByLocale[$locale->id] ?? null;
150
151                if ($path) {
152                    $location = $context->config->app->urlPrefix . $path;
153
154                    return Response::create($this->factory)->redirect(
155                        $query === '' ? $location : $location . '?' . $query,
156                        301,
157                    );
158                }
159
160                $locale = $locale->fallback();
161            }
162        }
163
164        return null;
165    }
166}