Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.32% covered (success)
97.32%
145 / 149
90.32% covered (success)
90.32%
28 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Panel
97.32% covered (success)
97.32%
145 / 149
90.32% covered (success)
90.32%
28 / 31
71
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
 render
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 tooManyAttempts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 context
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
5
 layer
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 csrfToken
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 target
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 messages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 panelPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listingRedirect
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 homeUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 formData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localeId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 panelLocales
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 stylesheets
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 scripts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moduleScripts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 client
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extras
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logo
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 collections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 navigation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ref
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 listing
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 firstUrl
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 menusUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 currentUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 actor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 permits
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 account
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 renderIcon
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Exception\HttpForbidden;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Request;
12use Celema\Core\Response;
13use Celema\Verba\Verba;
14use Celema\Wire\Creator;
15use Cosray\Actor;
16use Cosray\Cms;
17use Cosray\Collection\Listing;
18use Cosray\Collection\Ref;
19use Cosray\Collection\Schemas;
20use Cosray\Config;
21use Cosray\Context;
22use Cosray\Contract\Columns;
23use Cosray\Contract\Entries;
24use Cosray\Exception\RuntimeException;
25use Cosray\Icons\Provider as IconProvider;
26use Cosray\Locale;
27use Cosray\Navigation;
28use Cosray\NavigationItem;
29use Cosray\NavLink;
30use Cosray\Node\Types;
31use Cosray\Panel\Client;
32use Cosray\Panel\Extras;
33use Cosray\Renderer;
34use Cosray\Security\Policy;
35use Cosray\Session;
36use Cosray\User;
37use Cosray\Util\Form;
38use Cosray\Util\LiveReload;
39
40abstract class Panel
41{
42    /**
43     * Which masthead area this screen belongs to. The rail renders only for
44     * `content`, which is the default because a project's own panel pages put
45     * their entry in that rail without knowing this constant exists.
46     */
47    protected const string AREA = 'content';
48
49    protected string $panelDir;
50    private ?Client $client = null;
51
52    public function __construct(
53        protected Config $config,
54        protected Container $container,
55        protected readonly Request $request,
56    ) {
57        $this->panelDir = __DIR__ . '/../../../panel';
58    }
59
60    /**
61     * Renders a panel page right away, for a status other than the 200 that
62     * the route's renderer gives a returned context.
63     *
64     * @param non-empty-string $template
65     * @param array<string, mixed> $context
66     */
67    protected function render(Factory $factory, string $template, array $context, int $status): Response
68    {
69        $renderer = $this->container->tag(Renderer::class)->get('panel');
70
71        return Response::create($factory)->html($renderer->render($template, $context), $status);
72    }
73
74    protected function tooManyAttempts(int $seconds): string
75    {
76        return __n('auth:too-many-attempts', 'auth:too-many-attempts-plural', (int) ceil($seconds / 60));
77    }
78
79    /**
80     * @param array<string, mixed> $data
81     * @return array<string, mixed>
82     */
83    protected function context(array $data = []): array
84    {
85        $panelPath = $this->panelPath();
86        $localeId = $this->localeId();
87        $collections = $this->collections();
88        $layer = $this->layer();
89
90        return array_merge(
91            [
92                'debug' => $this->config->debug(),
93                'env' => $this->config->env(),
94                'layer' => $layer,
95                'panelPath' => $panelPath,
96                'panelBase' => $panelPath === '/' ? '/' : rtrim($panelPath, '/') . '/',
97                'currentPath' => $this->request->uri()->getPath(),
98                'area' => static::AREA,
99                'dashboard' => $this->config->panel->dashboard,
100                'homeUrl' => $this->homeUrl(),
101                'contentUrl' => $this->firstUrl($collections),
102                'menusUrl' => $this->menusUrl($panelPath),
103                'systemUrl' => $this->permits('edit-users') ? $panelPath . '/users' : null,
104                'logo' => $this->logo(),
105                'localeId' => $localeId,
106                'panelLocales' => $this->panelLocales(),
107                'account' => $this->account(),
108                'config' => $this->config,
109                'renderIcon' => $this->renderIcon(...),
110                'assetsBase' => $this->client()->url(),
111                'importMap' => $this->client()->importMap(),
112                'stylesheets' => $this->stylesheets(),
113                'scripts' => $this->scripts(),
114                'moduleScripts' => $this->moduleScripts(),
115                'liveReload' => LiveReload::url($this->request),
116                'csrfToken' => $this->csrfToken(),
117                // Only a full document loads the entry; a swap reuses the modules.
118                'modulePreloads' => $layer === 'document' ? $this->client()->preloads() : [],
119                'collections' => $collections,
120                'rail' => static::AREA === 'content' && $collections !== [],
121                'messages' => $this->messages(),
122            ],
123            $data,
124        );
125    }
126
127    /**
128     * How much of the panel a response renders. htmx names the element it is
129     * about to swap, and that boundary is where the layer templates stop: the
130     * content region for navigation inside an area, the frame for an area
131     * switch, the whole body for a history restore, the document otherwise.
132     */
133    protected function layer(): string
134    {
135        // A restore swaps the body. It has to come first: htmx sends this header
136        // alone, without the ones every other request carries.
137        if ($this->request->hasHeader('HX-History-Restore-Request')) {
138            return 'shell';
139        }
140
141        if (!$this->request->hasHeader('HX-Request')) {
142            return 'document';
143        }
144
145        // Anything else aimed at the body, which htmx flags as a full render.
146        if ($this->request->header('HX-Request-Type') === 'full') {
147            return 'shell';
148        }
149
150        return $this->target() === 'frame' ? 'frame' : 'main';
151    }
152
153    /** The session's CSRF token, which unsafe panel requests must carry. */
154    protected function csrfToken(): string
155    {
156        $session = $this->request->get('session', null);
157
158        if (!$session instanceof Session) {
159            return '';
160        }
161
162        // A sign-out earlier in this request ended the session, and the token needs a new
163        // one. Without strict mode, start() alone would resume the id whose cookie is deleted.
164        if (!$session->active()) {
165            $session->start();
166            $session->regenerate();
167        }
168
169        return $session->csrf->token();
170    }
171
172    /** The id of the element an htmx request swaps, if any. */
173    protected function target(): string
174    {
175        // The header reads `<tag>#<id>`; only the id names a panel region.
176        $target = $this->request->header('HX-Target');
177        $hash = strrpos($target, '#');
178
179        return $hash === false ? '' : substr($target, $hash + 1);
180    }
181
182    /**
183     * The `panel` catalog for the active locale as the payload the panel's
184     * verba runtime boots from. This domain holds exactly the strings the
185     * panel scripts use (extracted by the JavascriptScanner), so the browser
186     * never receives backend-only messages. Empty when no translator is
187     * active (e.g. outside the request pipeline).
188     *
189     * @return array{
190     *     locale: string,
191     *     domains: list<array{
192     *         domain: string,
193     *         plural: string,
194     *         messages: array<array-key, string|list<string>>,
195     *         contexts?: array<array-key, array<array-key, string|list<string>>>,
196     *     }>,
197     * }
198     */
199    protected function messages(): array
200    {
201        return Verba::translator()?->exportMany(['panel']) ?? ['locale' => $this->localeId(), 'domains' => []];
202    }
203
204    protected function panelPath(): string
205    {
206        return $this->config->panel->path;
207    }
208
209    /**
210     * A 303 back to a collection listing with the listing query the request
211     * URL carried, so the editor lands on the view they acted in.
212     *
213     * @param array<string, string> $extra
214     */
215    protected function listingRedirect(Factory $factory, string $collection, array $extra = []): Response
216    {
217        $params = [];
218
219        foreach (['q', 'sort', 'dir', 'offset', 'limit', 'parent', 'view', 'open'] as $key) {
220            $value = $this->request->param($key, '');
221
222            if (is_string($value) && trim($value) !== '') {
223                $params[$key] = trim($value);
224            }
225        }
226
227        $path = $this->panelPath() . '/collection/' . rawurlencode($collection);
228        $query = http_build_query([...$params, ...$extra], '', '&', PHP_QUERY_RFC3986);
229
230        return Response::create($factory)->redirect($query === '' ? $path : $path . '?' . $query, 303);
231    }
232
233    /**
234     * Where the logo and the panel root lead. Without the dashboard that is the
235     * first remaining masthead area: content, or media for a project without
236     * collections.
237     */
238    protected function homeUrl(): string
239    {
240        $panelPath = $this->panelPath();
241
242        if ($this->config->panel->dashboard) {
243            return $panelPath;
244        }
245
246        return $this->firstUrl($this->collections()) ?? $panelPath . '/media';
247    }
248
249    /**
250     * Submitted form data with fallbacks for request pipelines that do
251     * not populate the parsed body (JSON and urlencoded raw bodies).
252     *
253     * @return array<array-key, mixed>
254     */
255    protected function formData(): array
256    {
257        return Form::body($this->request);
258    }
259
260    protected function localeId(): string
261    {
262        $panelLocale = $this->request->get('panelLocale', null);
263
264        if (is_string($panelLocale)) {
265            return $panelLocale;
266        }
267
268        $locale = $this->request->get('locale', null);
269
270        return $locale instanceof Locale ? $locale->id : 'en';
271    }
272
273    /**
274     * The selectable panel UI languages, mapped to their capitalized native
275     * names (e.g. `['de' => 'Deutsch', 'fr' => 'Français', 'pt_BR' =>
276     * 'Português (Brasil)']`).
277     *
278     * @return array<string, string>
279     */
280    protected function panelLocales(): array
281    {
282        $ids = $this->request->get('panelLocales', []);
283        $titles = [];
284
285        /** @var string $id */
286        foreach (is_array($ids) ? $ids : [] as $id) {
287            $title = \Locale::getDisplayName($id, $id);
288            $titles[$id] = $title === false ? $id : mb_ucfirst($title);
289        }
290
291        return $titles;
292    }
293
294    /** @return list<string> */
295    private function stylesheets(): array
296    {
297        return [
298            ...$this->config->panel->theme,
299            $this->client()->url('styles/panel.css'),
300            ...$this->extras()->css(),
301        ];
302    }
303
304    /**
305     * Classic scripts, htmx first: plugins rely on its global.
306     *
307     * @return list<string>
308     */
309    private function scripts(): array
310    {
311        return [...$this->client()->scripts(), ...$this->extras()->scripts()];
312    }
313
314    /** @return list<string> */
315    private function moduleScripts(): array
316    {
317        return [$this->client()->url('src/panel.js'), ...$this->extras()->moduleScripts()];
318    }
319
320    protected function client(): Client
321    {
322        return $this->client ??= new Client($this->config);
323    }
324
325    private function extras(): Extras
326    {
327        return $this->container->instance(Extras::class);
328    }
329
330    private function logo(): ?string
331    {
332        $logo = $this->config->panel->logo;
333
334        if ($logo === null) {
335            return null;
336        }
337
338        $logo = trim((string) $logo);
339
340        return $logo === '' ? null : $logo;
341    }
342
343    /** @return list<NavigationItem> */
344    protected function collections(): array
345    {
346        return $this->navigation()->items();
347    }
348
349    protected function navigation(): Navigation
350    {
351        return $this->container->instance(Navigation::class);
352    }
353
354    /** The registered collection a panel URL names; unknown handles are not found. */
355    protected function ref(string $handle): Ref
356    {
357        try {
358            return $this->navigation()->ref($handle);
359        } catch (RuntimeException $e) {
360            throw new HttpNotFound($this->request, previous: $e);
361        }
362    }
363
364    /**
365     * The panel listing of a registered collection. A collection class is
366     * only instantiated when it implements Entries or Columns; it is then
367     * created for the request and shares the listing's CMS instance.
368     */
369    protected function listing(Ref $ref): Listing
370    {
371        $creator = new Creator($this->container);
372        $context = $creator->resolve(Context::class, predefinedTypes: [Request::class => $this->request]);
373        assert($context instanceof Context, 'The request context must be available');
374        $cms = $creator->resolve(Cms::class, predefinedTypes: [Context::class => $context]);
375        assert($cms instanceof Cms, 'The CMS must be available');
376        $collection = is_a($ref->class, Entries::class, true) || is_a($ref->class, Columns::class, true)
377            ? $context->create($ref->class, [Cms::class => $cms])
378            : null;
379        $schemas = $this->container->instance(Schemas::class);
380        $types = $this->container->instance(Types::class);
381
382        return new Listing($schemas->of($ref->class), $cms, $types, $collection);
383    }
384
385    /**
386     * Where the masthead's content entry goes: the first entry of the rail, in
387     * the rail's own order. Null when a project defines no collections.
388     *
389     * @param list<NavigationItem> $items
390     */
391    private function firstUrl(array $items): ?string
392    {
393        foreach ($items as $item) {
394            if ($item instanceof NavLink) {
395                return $item->url;
396            }
397
398            $slug = $item->slug();
399
400            if ($slug !== null) {
401                return $this->panelPath() . '/collection/' . $slug;
402            }
403
404            $url = $this->firstUrl($item->children());
405
406            if ($url !== null) {
407                return $url;
408            }
409        }
410
411        return null;
412    }
413
414    /**
415     * The masthead's menus entry, null for users without the permission.
416     * Cosmetic gating only â€” the menu routes enforce `edit-menus`
417     * themselves through the permission middleware.
418     */
419    private function menusUrl(string $panelPath): ?string
420    {
421        return $this->permits('edit-menus') ? $panelPath . '/menus' : null;
422    }
423
424    /**
425     * The user PanelAuth attached to the request, signed in by session or by
426     * token alike. Routes without PanelAuth have none.
427     */
428    protected function currentUser(): User
429    {
430        $user = $this->request->get('user', null);
431
432        return $user instanceof User ? $user : throw new HttpForbidden($this->request);
433    }
434
435    /** The current user as the author of a change. */
436    protected function actor(): Actor
437    {
438        return new Actor($this->currentUser()->id);
439    }
440
441    protected function permits(string $permission): bool
442    {
443        $user = $this->request->get('user', null);
444
445        return $this->container
446            ->instance(Policy::class)
447            ->permits($user instanceof User ? $user : null, $permission);
448    }
449
450    /** @return array{name: ?string, initials: string, title: string, detail: string}|null */
451    private function account(): ?array
452    {
453        $user = $this->request->get('user', null);
454
455        if (!$user instanceof User) {
456            return null;
457        }
458
459        $title = $user->name ?? ($user->username !== '' ? $user->username : $user->email);
460
461        return [
462            'name' => $user->name,
463            'initials' => $user->initials(),
464            'title' => $title,
465            'detail' => $user->email !== $title ? $user->email : '',
466        ];
467    }
468
469    /** @param array{id: string, args?: array<array-key, mixed>}|null $icon */
470    private function renderIcon(?array $icon): string
471    {
472        if ($icon === null) {
473            return '';
474        }
475
476        $id = $icon['id'] ?? null;
477
478        if (!is_string($id) || trim($id) === '') {
479            return '';
480        }
481
482        $service = $this->container->instance(IconProvider::class);
483        $args = $icon['args'] ?? [];
484
485        return $service->icon(trim($id), is_array($args) ? $args : []);
486    }
487}