Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
AngelTypesController
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
8 / 8
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPermission
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 about
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 qrCode
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 join
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
8
 qrJoinEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getAngelType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers;
6
7use Engelsystem\Config\Config;
8use Engelsystem\Helpers\Authenticator;
9use Engelsystem\Helpers\Carbon;
10use Engelsystem\Http\Exceptions\HttpNotFound;
11use Engelsystem\Http\Request;
12use Engelsystem\Http\Response;
13use Engelsystem\Models\AngelType;
14use Engelsystem\Models\User\User;
15use Engelsystem\Models\UserAngelType;
16use Exception;
17use Firebase\JWT\BeforeValidException;
18use Firebase\JWT\ExpiredException;
19use Firebase\JWT\JWT;
20use Firebase\JWT\Key;
21use Firebase\JWT\SignatureInvalidException;
22use Illuminate\Support\Str;
23use Psr\Http\Message\ServerRequestInterface;
24use Psr\Log\LoggerInterface;
25use UnexpectedValueException;
26
27class AngelTypesController extends BaseController
28{
29    use HasUserNotifications;
30
31    public function __construct(
32        protected Response $response,
33        protected Config $config,
34        protected Authenticator $auth,
35        protected LoggerInterface $log,
36        protected AngelType $angelType,
37    ) {
38    }
39
40    public function hasPermission(ServerRequestInterface $request, string $method): ?bool
41    {
42        return match ($method) {
43            'qrCode' =>
44                $this->auth->user()?->isAngelTypeSupporter($this->getAngelType($request))
45                || $this->auth->can('user.angeltypes.edit'),
46            'join' => (bool) $this->auth->user(),
47            default => parent::hasPermission($request, $method),
48        };
49    }
50
51    public function about(): Response
52    {
53        $angeltypes = AngelType::all();
54
55        return $this->response->withView(
56            'pages/angeltypes/about',
57            ['angeltypes' => $angeltypes]
58        );
59    }
60
61    public function index(): Response
62    {
63        $angelTypes = $this->angelType
64            ->with(['userAngelTypes' => function ($query): void {
65                $query->where('user_id', '=', auth()->user()->id);
66            }])
67            ->orderBy('name')
68            ->get();
69
70        return $this->response->withView(
71            'pages/angeltypes/index',
72            [
73                'angelTypes' => $angelTypes,
74                'is_index' => true,
75            ]
76        );
77    }
78
79    public function qrCode(Request $request): Response
80    {
81        $this->qrJoinEnabled();
82        $angelType = $this->getAngelType($request);
83        $jwtExpirationMin = $this->config->get('jwt_expiration_time');
84        $qrData = null;
85        $data = [];
86
87        if ($request->isMethod('post')) {
88            $data = $this->validate($request, [
89                'minutes' => 'required|int|min:1|max:' . $jwtExpirationMin,
90            ]);
91            $minutes = (int) $data['minutes'];
92            $time = Carbon::now();
93
94            $key = $this->config->get('app_key');
95            $alg = $this->config->get('jwt_algorithm');
96            $jti = Str::random();
97
98            $iat = $time->timestamp;
99            $exp = $time->addMinutes($minutes)->timestamp;
100            $data['expires'] = $time;
101
102            $payload = [
103                'sub' => 'join_angel_type',
104                'iat' => $iat,
105                'exp' => $exp,
106                'jti' => $jti,
107                'id' => $angelType->id,
108                'by' => $this->auth->user()->id,
109            ];
110            $jwt = JWT::encode($payload, $key, $alg);
111            $qrData = url('/angeltypes/' . $angelType->id . '/join', ['token' => $jwt]);
112        }
113
114        return $this->response->withInput($data)->withView(
115            'pages/angeltypes/qr',
116            ['angel_type' => $angelType, 'qr_data' => $qrData, 'qr_max_expiration_minutes' => $jwtExpirationMin],
117        );
118    }
119
120    public function join(Request $request): Response
121    {
122        $this->qrJoinEnabled();
123        $angelType = $this->getAngelType($request);
124
125        $jwt = $request->get('token', '');
126
127        $key = $this->config->get('app_key');
128        $alg = $this->config->get('jwt_algorithm');
129
130        try {
131            $decoded = JWT::decode($jwt, new Key($key, $alg));
132        } catch (BeforeValidException | ExpiredException) {
133            throw new HttpNotFound('jwt.invalid_time');
134        } catch (UnexpectedValueException | SignatureInvalidException $e) {
135            $this->log->warning('JWT: {message}, token: "{token}"', ['message' => $e->getMessage(), 'token' => $jwt]);
136            if ($e->getMessage() === 'Wrong number of segments') {
137                throw new HttpNotFound('jwt.wrong_format');
138            }
139            throw new HttpNotFound('jwt.code_error');
140        } catch (Exception $e) {
141            $this->log->error('JWT Error, token: "{token}"', ['exception' => $e, 'token' => $jwt]);
142            throw new HttpNotFound();
143        }
144
145        $type = $decoded->sub ?? null;
146        $id = $decoded->id ?? null;
147        $jti = $decoded->jti ?? null;
148        if ($type !== 'join_angel_type' || $id !== $angelType->id) {
149            throw new HttpNotFound();
150        }
151
152        /** @var User $confirmingUser */
153        $confirmingUser = User::findOrFail($decoded->by ?? null);
154        /** @var UserAngelType $userAngelType */
155        $userAngelType = UserAngelType::firstOrNew([
156            'user_id' => $this->auth->user()->id,
157            'angel_type_id' => $angelType->id,
158        ]);
159
160        if (!$userAngelType->confirmUser) {
161            $userAngelType->confirmUser()->associate($confirmingUser);
162
163            $this->log->info(
164                'Joined angel type {type} ({type_id}) via QR token {token_id} '
165                . 'created by {confirming_user} ({confirming_id})',
166                [
167                    'type' => $angelType->name,
168                    'type_id' => $angelType->id,
169                    'token_id' => $jti,
170                    'confirming_user' => $confirmingUser->name,
171                    'confirming_id' => $confirmingUser->id,
172                ]
173            );
174
175            $userAngelType->save();
176        }
177
178        $this->addNotification('angeltype.add.success');
179
180        return redirect(url('/angeltypes', ['action' => 'view', 'angeltype_id' => $angelType->id]));
181    }
182
183    protected function qrJoinEnabled(): void
184    {
185        if ($this->config->get('app_key') && $this->config->get('join_qr_code', true)) {
186            return;
187        }
188
189        throw new HttpNotFound();
190    }
191
192    protected function getAngelType(ServerRequestInterface $request): AngelType
193    {
194        $angelTypeId = (int) $request->getAttribute('angel_type_id');
195        /** @var AngelType $angelType */
196        $angelType = AngelType::findOrFail($angelTypeId);
197        return $angelType;
198    }
199}