Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
414 / 414
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Routes
100.00% covered (success)
100.00%
414 / 414
100.00% covered (success)
100.00%
6 / 6
13
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 catchallRoute
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 verifyCsrf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addApi
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 addPanel
100.00% covered (success)
100.00%
363 / 363
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Core\App;
8use Celema\Core\Factory\Factory;
9use Celema\Quma\Database;
10use Celema\Router\Group;
11use Celema\Router\Route;
12use Closure;
13use Cosray\Controller\Media;
14use Cosray\Controller\Node;
15use Cosray\Controller\Panel;
16use Cosray\Middleware\InitRequest;
17use Cosray\Middleware\PanelAuth;
18use Cosray\Middleware\PanelFraming;
19use Cosray\Middleware\PanelLocale;
20use Cosray\Middleware\Session;
21use Cosray\Middleware\VerifyCsrf;
22use Cosray\Security\Policy;
23use Psr\Log\LoggerInterface as Logger;
24use Psr\Log\NullLogger;
25
26class Routes
27{
28    protected string $panelPath;
29    protected InitRequest $initRequestMiddleware;
30    protected Session $session;
31    protected bool $frontendSession;
32
33    /**
34     * @param list<Closure(App): void> $pluginRoutes
35     * @param list<array{pattern: string, endpoint: mixed, template: non-empty-string, name: string}> $panelPages
36     */
37    public function __construct(
38        protected Config $config,
39        protected Database $db,
40        protected Factory $factory,
41        protected Policy $policy,
42        protected Users $users,
43        protected array $pluginRoutes = [],
44        protected array $panelPages = [],
45        protected Logger $logger = new NullLogger(),
46    ) {
47        $this->panelPath = $config->panel->path;
48        $this->frontendSession = $config->session->enabled;
49        $this->initRequestMiddleware = new InitRequest();
50        $this->session = new Session($this->config, $this->users);
51    }
52
53    public function add(App $app): void
54    {
55        $sessionIfEnabled = [
56            $app->get('/', [Node::class, 'catchall'], 'cms.index.get'),
57            $app->post('/', [Node::class, 'catchall'], 'cms.index.post'),
58            $app->put('/', [Node::class, 'catchall'], 'cms.index.put'),
59            $app->delete('/', [Node::class, 'catchall'], 'cms.index.delete'),
60        ];
61
62        $app->get(
63            '/preview/{uid:[A-Za-z0-9-_.]{1,64}}',
64            [Node::class, 'preview'],
65            'cms.preview',
66        )->middleware($this->session);
67
68        // Rendition fallback: the web server serves existing cache files
69        // natively; only misses reach PHP and get generated once.
70        $app->get(
71            '/' . trim($this->config->path->cache, '/') . '/...slug',
72            [Media::class, 'cache'],
73            'cms.media.cache',
74        );
75
76        $app->post(
77            '/media/{mediatype:(image|file|video)}',
78            [Media::class, 'upload'],
79            'cms.media.upload',
80        )->middleware($this->session, $this->verifyCsrf());
81
82        $this->addPanel($app);
83        $this->addApi($app);
84
85        foreach ($this->pluginRoutes as $addRoutes) {
86            $addRoutes($app);
87        }
88
89        if ($this->frontendSession) {
90            foreach ($sessionIfEnabled as $route) {
91                $route->middleware($this->session);
92            }
93        }
94    }
95
96    public function catchallRoute(): Route
97    {
98        $catchallRoute = Route::map(
99            ['GET', 'POST', 'PUT', 'DELETE'],
100            '/...slug',
101            [Node::class, 'catchall'],
102            'cms.catchall',
103        )->middleware($this->initRequestMiddleware);
104
105        if ($this->frontendSession) {
106            $catchallRoute->middleware($this->session);
107        }
108
109        return $catchallRoute;
110    }
111
112    protected function verifyCsrf(): VerifyCsrf
113    {
114        return new VerifyCsrf($this->config, $this->users, $this->factory, $this->logger);
115    }
116
117    protected function addApi(App $app): void
118    {
119        $path = $this->config->api->path;
120
121        if ($path === null) {
122            return;
123        }
124
125        $responses = new Api\Responses($this->factory);
126        new Api\Routes(
127            new Api\Headers($responses, $this->config->api->origins, $this->config->api->maxAge),
128            new Api\Problems($responses, $this->logger, $this->config->debug()),
129            $this->users,
130        )->add($app, $path);
131    }
132
133    protected function addPanel(App $app): void
134    {
135        // A page loads a hundred of the panel's files, and none of them needs
136        // the session or the user the panel's middleware would load for each.
137        $app->group(
138            $this->panelPath,
139            static function (Group $files): void {
140                $files->get('/assets/{version:[a-z0-9]{1,40}}/...slug', [Panel\Assets::class, 'asset'], 'asset');
141                $files->get(
142                    '/vendor/{plugin:[a-z0-9-]{1,64}}/...slug',
143                    [Panel\Assets::class, 'vendor'],
144                    'vendor.asset',
145                );
146            },
147            'cms.panel.',
148        );
149        $app->group(
150            $this->panelPath,
151            function (Group $panel) use ($app) {
152                $renderers = new PanelRenderers($app);
153                $panelAuth = new PanelAuth(
154                    $this->config,
155                    $this->users,
156                    $this->factory,
157                    $this->policy,
158                );
159                // PanelLocale runs after Session so the user attribute (and
160                // with it the stored panel language preference) is available,
161                // and before VerifyCsrf, whose refusal is in that language.
162                $panel->middleware(
163                    new PanelFraming($this->config->panel->embed),
164                    $this->session,
165                    new PanelLocale($this->config),
166                    $this->verifyCsrf(),
167                );
168
169                $panel
170                    ->get('/login', [Panel\Login::class, 'login'], 'login')
171                    ->after($renderers->get('login'));
172                $panel
173                    ->post('/login', [Panel\Login::class, 'authenticate'], 'login.authenticate')
174                    ->after($renderers->get('login'));
175                $panel->post('/login/locale', [Panel\Preferences::class, 'guestLocale'], 'login.locale');
176                $panel->get('/handoff/{token:[a-f0-9]{64}}', [Panel\Login::class, 'handoff'], 'handoff');
177                $panel
178                    ->get('/expired', [Panel\Login::class, 'expired'], 'expired')
179                    ->after($renderers->get('expired'));
180                $panel
181                    ->get('/password', [Panel\PasswordReset::class, 'form'], 'password')
182                    ->after($renderers->get('password'));
183                $panel
184                    ->post('/password', [Panel\PasswordReset::class, 'send'], 'password.send')
185                    ->after($renderers->get('password'));
186                $panel
187                    ->get('/password/{token:[a-f0-9]{64}}', [Panel\PasswordReset::class, 'choose'], 'password.choose')
188                    ->after($renderers->get('password'));
189                $panel
190                    ->post('/password/{token:[a-f0-9]{64}}', [Panel\PasswordReset::class, 'change'], 'password.change')
191                    ->after($renderers->get('password'));
192                $panel
193                    ->post('/logout', [Panel\Login::class, 'logout'], 'logout')
194                    ->middleware($panelAuth);
195                $panel
196                    ->post('/locale', [Panel\Preferences::class, 'locale'], 'locale')
197                    ->middleware($panelAuth);
198                $panel
199                    ->get(
200                        '',
201                        [Panel\Index::class, 'index'],
202                        'index',
203                    )
204                    ->middleware($panelAuth)
205                    ->after($renderers->get('index'));
206
207                if ($this->config->debug()) {
208                    $panel
209                        ->get(
210                            '/styleguide',
211                            [Panel\Styleguide::class, 'index'],
212                            'styleguide',
213                        )
214                        ->middleware($panelAuth)
215                        ->after($renderers->get('styleguide'));
216                }
217
218                $panel
219                    ->get(
220                        '/media',
221                        [Panel\Media::class, 'index'],
222                        'media',
223                    )
224                    ->middleware($panelAuth)
225                    ->after($renderers->get('media'));
226                $uid = '{uid:[A-Za-z0-9-_.]{1,64}}';
227                $type = '{type:[a-z][a-z0-9-]{0,63}}';
228                $panel
229                    ->get('/media/picker', [Panel\Media::class, 'picker'], 'media.picker')
230                    ->middleware($panelAuth)
231                    ->after($renderers->get('media'));
232                $panel
233                    ->get('/media/search', [Panel\Media::class, 'search'], 'media.search')
234                    ->middleware($panelAuth);
235                $panel
236                    ->post("/media/{$uid}", [Panel\Media::class, 'save'], 'media.save')
237                    ->middleware($panelAuth)
238                    ->after($renderers->get('media'));
239                $panel
240                    ->post("/media/{$uid}/delete", [Panel\Media::class, 'delete'], 'media.delete')
241                    ->middleware($panelAuth)
242                    ->after($renderers->get('media'));
243                $panel
244                    ->get('/users', [Panel\Users::class, 'index'], 'users')
245                    ->middleware($panelAuth)
246                    ->after($renderers->get('users'));
247                // Before the {uid} routes: the literal segment wins over a uid.
248                $panel
249                    ->get("/users/create/{$type}", [Panel\Users::class, 'create'], 'users.create')
250                    ->middleware($panelAuth)
251                    ->after($renderers->get('user'));
252                $panel
253                    ->post("/users/create/{$type}", [Panel\Users::class, 'store'], 'users.store')
254                    ->middleware($panelAuth)
255                    ->after($renderers->get('editor-save'));
256                $panel
257                    ->get("/users/{$uid}", [Panel\Users::class, 'edit'], 'users.edit')
258                    ->middleware($panelAuth)
259                    ->after($renderers->get('user'));
260                $panel
261                    ->post("/users/{$uid}", [Panel\Users::class, 'update'], 'users.update')
262                    ->middleware($panelAuth)
263                    ->after($renderers->get('editor-save'));
264                $panel
265                    ->get('/profile', [Panel\Users::class, 'profile'], 'profile')
266                    ->middleware($panelAuth)
267                    ->after($renderers->get('user'));
268                $panel
269                    ->post('/profile', [Panel\Users::class, 'saveProfile'], 'profile.save')
270                    ->middleware($panelAuth)
271                    ->after($renderers->get('editor-save'));
272                $panel
273                    ->get('/profile/tokens', [Panel\Tokens::class, 'index'], 'profile.tokens')
274                    ->middleware($panelAuth)
275                    ->after($renderers->get('tokens'));
276                $panel
277                    ->post('/profile/tokens', [Panel\Tokens::class, 'store'], 'profile.tokens.store')
278                    ->middleware($panelAuth)
279                    ->after($renderers->get('tokens'));
280                $panel
281                    ->post("/profile/tokens/{$uid}/delete", [Panel\Tokens::class, 'revoke'], 'profile.tokens.revoke')
282                    ->middleware($panelAuth);
283                $panel
284                    ->post("/users/{$uid}/delete", [Panel\Users::class, 'delete'], 'users.delete')
285                    ->middleware($panelAuth)
286                    ->after($renderers->get('editor-save'));
287                $panel
288                    ->get(
289                        '/menus',
290                        [Panel\Menus::class, 'index'],
291                        'menus',
292                    )
293                    ->middleware($panelAuth)
294                    ->after($renderers->get('menus'));
295                // Before the {menu} routes: the literal segment wins over a
296                // menu handle of the same name, and the handle validation
297                // reserves it.
298                $panel
299                    ->get(
300                        '/menus/create',
301                        [Panel\Menus::class, 'create'],
302                        'menus.create',
303                    )
304                    ->middleware($panelAuth)
305                    ->after($renderers->get('menu-form'));
306                $panel
307                    ->post(
308                        '/menus/create',
309                        [Panel\Menus::class, 'store'],
310                        'menus.store',
311                    )
312                    ->middleware($panelAuth)
313                    ->after($renderers->get('menu-form'));
314                // The menu's own fields live on its tree screen, so this only
315                // takes the write and answers with that screen.
316                $panel
317                    ->post(
318                        '/menus/{menu:[a-z0-9-]{1,32}}/edit',
319                        [Panel\Menus::class, 'update'],
320                        'menu.update',
321                    )
322                    ->middleware($panelAuth)
323                    ->after($renderers->get('menu'));
324                $panel
325                    ->post(
326                        '/menus/{menu:[a-z0-9-]{1,32}}/delete',
327                        [Panel\Menus::class, 'delete'],
328                        'menu.delete',
329                    )
330                    ->middleware($panelAuth);
331                $panel
332                    ->get(
333                        '/menus/{menu:[a-z0-9-]{1,32}}',
334                        [Panel\Menus::class, 'menu'],
335                        'menu',
336                    )
337                    ->middleware($panelAuth)
338                    ->after($renderers->get('menu'));
339                // Before the {item} route: the literal segment wins, and item
340                // ids are generated uids that never collide with it.
341                $panel
342                    ->post(
343                        '/menus/{menu:[a-z0-9-]{1,32}}/item/create',
344                        [Panel\Menus::class, 'storeItem'],
345                        'menu.item.store',
346                    )
347                    ->middleware($panelAuth)
348                    ->after($renderers->get('menu'));
349                $panel
350                    ->post(
351                        '/menus/{menu:[a-z0-9-]{1,32}}/item/{item:[A-Za-z0-9_-]{1,64}}/move',
352                        [Panel\Menus::class, 'moveItem'],
353                        'menu.item.move',
354                    )
355                    ->middleware($panelAuth);
356                $panel
357                    ->post(
358                        '/menus/{menu:[a-z0-9-]{1,32}}/item/{item:[A-Za-z0-9_-]{1,64}}/delete',
359                        [Panel\Menus::class, 'deleteItem'],
360                        'menu.item.delete',
361                    )
362                    ->middleware($panelAuth);
363                $panel
364                    ->post(
365                        '/menus/{menu:[a-z0-9-]{1,32}}/item/{item:[A-Za-z0-9_-]{1,64}}',
366                        [Panel\Menus::class, 'updateItem'],
367                        'menu.item.update',
368                    )
369                    ->middleware($panelAuth)
370                    ->after($renderers->get('menu'));
371                $panel
372                    ->get(
373                        '/reference/search',
374                        [Panel\Reference::class, 'search'],
375                        'reference.search',
376                    )
377                    ->middleware($panelAuth);
378                $panel
379                    ->get(
380                        '/reference/nodes',
381                        [Panel\Reference::class, 'nodes'],
382                        'reference.nodes',
383                    )
384                    ->middleware($panelAuth);
385                $panel
386                    ->get(
387                        '/reference/labels',
388                        [Panel\Reference::class, 'labels'],
389                        'reference.labels',
390                    )
391                    ->middleware($panelAuth);
392                $panel
393                    ->get(
394                        '/collection/{collection}',
395                        [Panel\Collection::class, 'collection'],
396                        'collection',
397                    )
398                    ->middleware($panelAuth)
399                    ->after($renderers->get('collection'));
400                $panel
401                    ->get(
402                        '/node/create/{type:[A-Za-z0-9-_.]{1,64}}',
403                        [Panel\Editor::class, 'create'],
404                        'editor.create',
405                    )
406                    ->middleware($panelAuth)
407                    ->after($renderers->get('editor'));
408                $panel
409                    ->post(
410                        '/node/create/{type:[A-Za-z0-9-_.]{1,64}}',
411                        [Panel\Editor::class, 'store'],
412                        'editor.store',
413                    )
414                    ->middleware($panelAuth)
415                    ->after($renderers->get('editor-save'));
416                $panel
417                    ->post(
418                        '/node/create/{type:[A-Za-z0-9-_.]{1,64}}/paths',
419                        [Panel\Editor::class, 'createPaths'],
420                        'editor.create.paths',
421                    )
422                    ->middleware($panelAuth)
423                    ->after($renderers->get('editor-paths'));
424                $panel
425                    ->post(
426                        '/node/create/{type:[A-Za-z0-9-_.]{1,64}}/blocks/{field:[A-Za-z0-9_]{1,64}}',
427                        [Panel\Editor::class, 'createBlocks'],
428                        'editor.create.blocks',
429                    )
430                    ->middleware($panelAuth)
431                    ->after($renderers->get('blocks-preview'));
432                $panel
433                    ->post(
434                        '/collection/{collection}/position',
435                        [Panel\Collection::class, 'position'],
436                        'collection.position',
437                    )
438                    ->middleware($panelAuth);
439                $panel
440                    ->post(
441                        '/collection/{collection}/bulk/publish',
442                        [Panel\Bulk::class, 'publish'],
443                        'collection.bulk.publish',
444                    )
445                    ->middleware($panelAuth);
446                $panel
447                    ->post(
448                        '/collection/{collection}/bulk/delete',
449                        [Panel\Bulk::class, 'delete'],
450                        'collection.bulk.delete',
451                    )
452                    ->middleware($panelAuth);
453                $panel
454                    ->post(
455                        '/collection/{collection}/bulk/duplicate',
456                        [Panel\Bulk::class, 'duplicate'],
457                        'collection.bulk.duplicate',
458                    )
459                    ->middleware($panelAuth);
460                $panel
461                    ->post(
462                        '/node/{node:[A-Za-z0-9-_.]{1,64}}/delete',
463                        [Panel\Editor::class, 'delete'],
464                        'editor.delete',
465                    )
466                    ->middleware($panelAuth)
467                    ->after($renderers->get('editor-save'));
468                $panel
469                    ->post(
470                        '/node/{node:[A-Za-z0-9-_.]{1,64}}/discard',
471                        [Panel\Editor::class, 'discard'],
472                        'editor.discard',
473                    )
474                    ->middleware($panelAuth);
475                $panel
476                    ->post(
477                        '/node/{node:[A-Za-z0-9-_.]{1,64}}/paths',
478                        [Panel\Editor::class, 'paths'],
479                        'editor.paths',
480                    )
481                    ->middleware($panelAuth)
482                    ->after($renderers->get('editor-paths'));
483                $panel
484                    ->post(
485                        '/node/{node:[A-Za-z0-9-_.]{1,64}}/blocks/{field:[A-Za-z0-9_]{1,64}}',
486                        [Panel\Editor::class, 'blocks'],
487                        'editor.blocks',
488                    )
489                    ->middleware($panelAuth)
490                    ->after($renderers->get('blocks-preview'));
491                $panel
492                    ->get(
493                        '/node/{node:[A-Za-z0-9-_.]{1,64}}',
494                        [Panel\Editor::class, 'edit'],
495                        'editor',
496                    )
497                    ->middleware($panelAuth)
498                    ->after($renderers->get('editor'));
499                $panel
500                    ->post(
501                        '/node/{node:[A-Za-z0-9-_.]{1,64}}',
502                        [Panel\Editor::class, 'save'],
503                        'editor.save',
504                    )
505                    ->middleware($panelAuth)
506                    ->after($renderers->get('editor-save'));
507
508                foreach ($this->panelPages as $page) {
509                    $panel
510                        ->get($page['pattern'], $page['endpoint'], $page['name'])
511                        ->middleware($panelAuth)
512                        ->after($renderers->get($page['template']));
513                }
514            },
515            'cms.panel.',
516        );
517    }
518}