Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.72% covered (warning)
83.72%
180 / 215
47.06% covered (danger)
47.06%
8 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
OldPanel
83.72% covered (warning)
83.72%
180 / 215
47.06% covered (danger)
47.06%
8 / 17
56.53
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
 boot
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 catchall
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 collections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collection
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
7
 blueprint
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 nodePaths
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 createNode
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 node
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
7.08
 saveNode
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 themeStylesheets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 intParam
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
7.99
 stringParam
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getPanelIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 flattenCollections
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 navigation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller;
6
7use Celemas\Container\Container;
8use Celemas\Core\Exception\HttpBadRequest;
9use Celemas\Core\Exception\HttpNotFound;
10use Celemas\Core\Factory\Factory;
11use Celemas\Core\Request;
12use Celemas\Core\Response;
13use Celemas\Wire\Creator;
14use Cosray\Cms;
15use Cosray\Config;
16use Cosray\Context;
17use Cosray\Locales;
18use Cosray\Middleware\Permission;
19use Cosray\Navigation;
20use Cosray\Node\Factory as NodeFactory;
21use Cosray\Node\Node;
22use Cosray\Node\PathManager;
23use Cosray\Node\RoutePathGenerator;
24use Cosray\Node\Serializer;
25use Cosray\Node\Store;
26use Cosray\Node\Types;
27use Cosray\Plugin;
28
29class OldPanel
30{
31    private const string PANEL_PATH = '/panel';
32    private const int LIMIT_DEFAULT = 50;
33    private const int LIMIT_MAX = 250;
34
35    protected string $publicPath;
36
37    public function __construct(
38        protected readonly Request $request,
39        protected readonly Config $config,
40        protected readonly Container $container,
41        protected readonly Locales $locales,
42        protected readonly Types $types,
43    ) {
44        $this->publicPath = $config->path->public;
45    }
46
47    public function boot(): array
48    {
49        $config = $this->config;
50        $localesList = array_map(
51            static fn($locale) => [
52                'id' => $locale->id,
53                'title' => $locale->title,
54                'fallback' => $locale->fallback,
55            ],
56            iterator_to_array($this->locales),
57            [], // Add an empty array to remove the assoc array keys
58            //    See: https://www.php.net/manual/en/function.array-map.php#refsect1-function.array-map-returnvalues
59        );
60
61        return [
62            'locales' => $localesList,
63            'locale' => $this->locales->getDefault()->id, // TODO: set the correct user locale
64            'defaultLocale' => $this->locales->getDefault()->id,
65            'debug' => $config->debug(),
66            'env' => $config->env(),
67            'csrfToken' => '', // TODO: real token
68            'logo' => $config->panel->logo,
69            'theme' => $this->themeStylesheets(),
70            'api' => $config->path->api,
71            'assets' => $config->path->assets,
72            'cache' => $config->path->cache,
73            'prefix' => $config->path->prefix,
74            'sessionExpires' => $config->session->options['gc_maxlifetime'],
75            'transliterate' => [],
76            'allowedFiles' => [
77                'file' => array_merge(...array_values($config->upload->file)),
78                'image' => array_merge(...array_values($config->upload->image)),
79                'video' => array_merge(...array_values($config->upload->video)),
80            ],
81        ];
82    }
83
84    public function index(Factory $factory): Response
85    {
86        return Response::create($factory)->file($this->getPanelIndex());
87    }
88
89    public function catchall(Factory $factory, string $slug): Response
90    {
91        $file = $this->publicPath . self::PANEL_PATH . '/' . $slug;
92
93        if (is_file($file)) {
94            return Response::create($factory)->file($file);
95        }
96
97        return Response::create($factory)->file($this->getPanelIndex());
98    }
99
100    #[Permission('panel')]
101    public function collections(): array
102    {
103        return $this->flattenCollections($this->navigation()->payload());
104    }
105
106    #[Permission('panel')]
107    public function collection(string $collection): array
108    {
109        $creator = new Creator($this->container);
110        $ref = $this->navigation()->ref($collection);
111        $obj = $creator->create(
112            $ref::class,
113            predefinedTypes: [Request::class => $this->request],
114        );
115        $blueprints = [];
116        $offset = $this->intParam('offset', 0, min: 0);
117        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
118        $q = $this->stringParam('q');
119        $sort = $this->stringParam('sort');
120        $dir = strtolower($this->stringParam('dir'));
121        $parent = $this->stringParam('parent');
122
123        if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) {
124            throw new HttpBadRequest($this->request);
125        }
126
127        $sorts = $obj->sorts();
128
129        if ($sort !== '' && !array_key_exists($sort, $sorts)) {
130            throw new HttpBadRequest($this->request);
131        }
132
133        $listing = $obj->list(
134            offset: $offset,
135            limit: $limit,
136            q: $q,
137            sort: $sort,
138            dir: $dir,
139            parent: $parent === '' ? null : $parent,
140        );
141
142        foreach ($obj->blueprints() as $blueprint) {
143            $blueprints[] = [
144                'slug' => (string) $this->types->get($blueprint, 'handle'),
145                'name' => (string) $this->types->get($blueprint, 'label'),
146            ];
147        }
148
149        return [
150            'name' => $ref->meta->label,
151            'meta' => $ref->meta->array(),
152            'slug' => $collection,
153            'header' => $obj->header(),
154            'showPublished' => $obj->listMeta->showPublished,
155            'showHidden' => $obj->listMeta->showHidden,
156            'showLocked' => $obj->listMeta->showLocked,
157            'showChildren' => $obj->listMeta->showChildren,
158            'total' => $listing['total'],
159            'offset' => $listing['offset'],
160            'limit' => $listing['limit'],
161            'q' => $listing['q'],
162            'sort' => $listing['sort'],
163            'dir' => $listing['dir'],
164            'sorts' => array_keys($sorts),
165            'nodes' => $listing['nodes'],
166            'blueprints' => $blueprints,
167        ];
168    }
169
170    #[Permission('panel')]
171    public function blueprint(string $type, Context $context, Cms $cms): array
172    {
173        $content = [];
174        $defaults = $this->request->param('content', null);
175
176        if ($defaults !== null) {
177            // TODO: check security concerns
178            $content = json_decode($defaults, true);
179        }
180
181        $factory = $cms->nodeFactory();
182        $class = $this->container
183            ->tag(Plugin::NODE_TAG)
184            ->entry($type)
185            ->definition();
186        $obj = $factory->blueprint($class, $context, $cms);
187
188        $serializer = new Serializer(
189            $factory->hydrator(),
190            $this->types,
191            $factory->uid(),
192        );
193
194        return $serializer->blueprint(
195            $obj,
196            NodeFactory::fieldNamesFor($obj),
197            $context->locales(),
198            $content,
199        );
200    }
201
202    #[Permission('panel')]
203    public function nodePaths(string $type, Context $context): array
204    {
205        if ($this->request->header('Content-Type') !== 'application/json') {
206            throw new HttpBadRequest($this->request);
207        }
208
209        $class = $this->container
210            ->tag(Plugin::NODE_TAG)
211            ->entry($type)
212            ->definition();
213        $generator = new RoutePathGenerator($context->db, $this->types);
214
215        return [
216            'paths' => $generator->preview(
217                $class,
218                $this->request->json(),
219                $context->locales(),
220            ),
221        ];
222    }
223
224    #[Permission('panel')]
225    public function createNode(
226        string $type,
227        Context $context,
228        Cms $cms,
229        Factory $factory,
230    ): Response {
231        if ($this->request->header('Content-Type') !== 'application/json') {
232            throw new HttpBadRequest($this->request);
233        }
234
235        $data = $this->request->json();
236        $class = $this->container
237            ->tag(Plugin::NODE_TAG)
238            ->entry($type)
239            ->definition();
240        $obj = $cms->nodeFactory()->create($class, $context, $cms, $data);
241
242        $store = new Store($context->db, new PathManager(), $this->types, $cms->nodeFactory()->uid());
243        $result = $store->create($obj, $data, $this->request, $context->locales());
244
245        return new Response(
246            $factory
247                ->response()
248                ->withStatus(201)
249                ->withHeader('Content-Type', 'application/json'),
250        )->body(json_encode($result));
251    }
252
253    #[Permission('panel')]
254    public function node(Context $context, Cms $cms, Factory $factory, string $uid): Response
255    {
256        $result = $cms->node->byUid($uid, published: null);
257
258        if (!$result) {
259            throw new HttpNotFound($this->request);
260        }
261
262        $node = Node::unwrap($result);
263        $nodeFactory = $cms->nodeFactory();
264        $serializer = new Serializer($nodeFactory->hydrator(), $this->types, $nodeFactory->uid());
265        $store = new Store($context->db, new PathManager(), $this->types, $nodeFactory->uid());
266        $method = $this->request->method();
267
268        $result = match ($method) {
269            'GET' => $serializer->read(
270                $node,
271                NodeFactory::dataFor($node),
272                NodeFactory::fieldNamesFor($node),
273            ),
274            'PUT' => $this->saveNode($node, $store, $context),
275            'DELETE' => $store->delete($node, $this->request),
276            default => throw new HttpBadRequest($this->request),
277        };
278
279        $content = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
280
281        return new Response(
282            $factory
283                ->response()
284                ->withStatus($method === 'POST' ? 201 : 200)
285                ->withHeader('Content-Type', 'application/json'),
286        )->body($content);
287    }
288
289    private function saveNode(object $node, Store $store, Context $context): array
290    {
291        if ($this->request->header('Content-Type') !== 'application/json') {
292            throw new HttpBadRequest($this->request);
293        }
294
295        return $store->save($node, $this->request->json(), $this->request, $context->locales());
296    }
297
298    private function themeStylesheets(): array
299    {
300        return $this->config->panel->theme;
301    }
302
303    private function intParam(
304        string $key,
305        int $default,
306        int $min,
307        ?int $max = null,
308    ): int {
309        $value = $this->request->param($key, (string) $default);
310
311        if (is_int($value)) {
312            $int = $value;
313        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
314            $int = (int) $value;
315        } else {
316            throw new HttpBadRequest($this->request);
317        }
318
319        if ($int < $min) {
320            throw new HttpBadRequest($this->request);
321        }
322
323        if ($max !== null && $int > $max) {
324            throw new HttpBadRequest($this->request);
325        }
326
327        return $int;
328    }
329
330    private function stringParam(string $key): string
331    {
332        $value = $this->request->param($key, '');
333
334        if (!is_string($value)) {
335            throw new HttpBadRequest($this->request);
336        }
337
338        return trim($value);
339    }
340
341    protected function getPanelIndex(): string
342    {
343        return $this->publicPath . self::PANEL_PATH . '/index.html';
344    }
345
346    /**
347     * Keep the old panel collections API backward-compatible.
348     *
349     * The legacy Svelte panel expects a flat sequence of section and collection items.
350     * Newer navigation payloads can be nested, so we flatten them depth-first here.
351     *
352     * @param list<array<string, mixed>> $items
353     * @return list<array<string, mixed>>
354     */
355    private function flattenCollections(array $items): array
356    {
357        $result = [];
358
359        foreach ($items as $item) {
360            if (!is_array($item)) {
361                continue;
362            }
363
364            $type = $item['type'] ?? null;
365            $meta = $item['meta'] ?? [];
366            $meta = is_array($meta) ? $meta : [];
367
368            if ($type === 'section') {
369                $result[] = [
370                    'type' => 'section',
371                    'name' => (string) ($item['name'] ?? ''),
372                    'meta' => $meta,
373                    'children' => [],
374                ];
375                $children = $item['children'] ?? [];
376
377                if (is_array($children)) {
378                    $result = array_merge($result, $this->flattenCollections($children));
379                }
380
381                continue;
382            }
383
384            if ($type !== 'collection') {
385                continue;
386            }
387
388            $result[] = [
389                'type' => 'collection',
390                'slug' => (string) ($item['slug'] ?? ''),
391                'name' => (string) ($item['name'] ?? ''),
392                'meta' => $meta,
393                'children' => [],
394            ];
395        }
396
397        return $result;
398    }
399
400    private function navigation(): Navigation
401    {
402        $navigation = $this->container->get(Navigation::class);
403        assert($navigation instanceof Navigation, 'The navigation service must be available');
404
405        return $navigation;
406    }
407}