Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
26 / 28
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Handoff
92.86% covered (success)
92.86%
26 / 28
66.67% covered (warning)
66.67%
2 / 3
13.06
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
 create
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 next
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api\Controller;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpForbidden;
9use Celema\Core\Exception\HttpUnauthorized;
10use Celema\Core\Request;
11use Cosray\Api\Responses;
12use Cosray\Config;
13use Cosray\Security\Policy;
14use Cosray\User;
15use Cosray\Users;
16use JsonException;
17use Psr\Http\Message\ResponseInterface as Response;
18
19/**
20 * Exchanges the token of a panel user for a link that opens a panel session
21 * once, within five minutes, for an application that frames the editor.
22 */
23final class Handoff
24{
25    public const int LIFETIME = 300;
26
27    public function __construct(
28        private readonly Responses $responses,
29    ) {}
30
31    public function create(Request $request, Users $users, Policy $policy, Config $config): Response
32    {
33        $user = $request->get('user', null);
34
35        if (!$user instanceof User) {
36            throw new HttpUnauthorized($request, payload: ['message' => 'A handoff needs the token of a panel user.']);
37        }
38
39        if (!$policy->permits($user, 'panel')) {
40            throw new HttpForbidden($request, payload: ['message' => 'The token\'s user has no panel access.']);
41        }
42
43        $next = $this->next($request);
44        $token = $users->createOneTimeToken($user->id);
45        $url = $config->app->urlPrefix . $config->panel->path . '/handoff/' . $token;
46
47        if ($next !== null) {
48            $url .= '?' . http_build_query(['next' => $config->panel->path . $next], '', '&', PHP_QUERY_RFC3986);
49        }
50
51        return $this->responses->json([
52            'token' => $token,
53            'url' => $url,
54            'expires' => gmdate('Y-m-d\TH:i:s\Z', time() + self::LIFETIME),
55        ], 201);
56    }
57
58    /** The panel path to open, relative to the panel, like `/node/abc?from=embed`. */
59    private function next(Request $request): ?string
60    {
61        $body = (string) $request->body();
62
63        if (trim($body) === '') {
64            return null;
65        }
66
67        try {
68            $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
69        } catch (JsonException) {
70            throw new HttpBadRequest($request, payload: ['message' => 'The body is not valid JSON.']);
71        }
72
73        $next = is_array($data) ? $data['next'] ?? null : null;
74
75        if ($next !== null && (!is_string($next) || !str_starts_with($next, '/') || str_starts_with($next, '//'))) {
76            throw new HttpBadRequest($request, payload: [
77                'message' => '`next` is a path inside the panel, like /node/abc.',
78            ]);
79        }
80
81        return $next;
82    }
83}