Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
115 / 115
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ApiKeyController
100.00% covered (success)
100.00%
115 / 115
100.00% covered (success)
100.00%
4 / 4
31
100.00% covered (success)
100.00%
1 / 1
 list
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 create
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
9
 update
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
12
 delete
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * The Admin API Key Controller
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use JsonException;
23use phpMyFAQ\Database;
24use phpMyFAQ\Enums\PermissionType;
25use phpMyFAQ\Filter;
26use phpMyFAQ\Translation;
27use Symfony\Component\HttpFoundation\JsonResponse;
28use Symfony\Component\HttpFoundation\Request;
29use Symfony\Component\HttpFoundation\Response;
30use Symfony\Component\Routing\Attribute\Route;
31
32final class ApiKeyController extends AbstractAdministrationApiController
33{
34    /**
35     * @throws \Exception
36     */
37    #[Route(path: 'user/api-keys', name: 'admin.api.user.api-keys.list', methods: ['GET'])]
38    public function list(): JsonResponse
39    {
40        $this->userHasPermission(PermissionType::USER_EDIT);
41
42        $db = $this->configuration->getDb();
43        $sql = sprintf('SELECT id, user_id, name, scopes, last_used_at, expires_at, created
44             FROM %sfaqapi_keys
45             WHERE user_id = %d
46             ORDER BY id DESC', Database::getTablePrefix(), $this->currentUser->getUserId());
47
48        $result = $db->query($sql);
49        $rows = $result === false ? [] : $db->fetchAll($result) ?? [];
50
51        foreach ($rows as &$row) {
52            $row = (array) $row;
53
54            $decoded = is_string($row['scopes'] ?? null) ? json_decode(json: $row['scopes'], associative: true) : null;
55            $row['scopes'] = is_array($decoded) ? $decoded : [];
56        }
57
58        unset($row);
59
60        return $this->json($rows, Response::HTTP_OK);
61    }
62
63    /**
64     * @throws \Exception
65     * @throws JsonException
66     */
67    #[Route(path: 'user/api-keys', name: 'admin.api.user.api-keys.create', methods: ['POST'])]
68    public function create(Request $request): JsonResponse
69    {
70        $this->userHasPermission(PermissionType::USER_EDIT);
71
72        $data = json_decode(json: $request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
73        $csrf = Filter::filterVar($data->csrf ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
74        if (!$this->verifySessionCsrfToken('api-key-create', $csrf)) {
75            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
76        }
77
78        $name = Filter::filterVar($data->name ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
79        if ($name === '') {
80            return $this->json(['error' => 'API key name is required.'], Response::HTTP_BAD_REQUEST);
81        }
82
83        $scopes = is_array($data->scopes ?? null) ? array_values($data->scopes) : [];
84        $scopes = array_values(array_filter($scopes, is_string(...)));
85        $expiresAt = Filter::filterVar($data->expiresAt ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
86
87        if ($expiresAt !== '' && strtotime($expiresAt) === false) {
88            return $this->json(['error' => 'Invalid expiresAt value.'], Response::HTTP_BAD_REQUEST);
89        }
90
91        $db = $this->configuration->getDb();
92        $id = $db->nextId(Database::getTablePrefix() . 'faqapi_keys', column: 'id');
93        $apiKey = 'pmf_' . bin2hex(random_bytes(20));
94        $apiKeyHash = hash('sha256', $apiKey);
95
96        $insert = sprintf(
97            "INSERT INTO %sfaqapi_keys
98                (id, user_id, api_key, name, scopes, last_used_at, expires_at, created)
99             VALUES
100                (%d, %d, '%s', '%s', '%s', NULL, %s, %s)",
101            Database::getTablePrefix(),
102            $id,
103            $this->currentUser->getUserId(),
104            $db->escape($apiKeyHash),
105            $db->escape($name),
106            $db->escape((string) json_encode($scopes)),
107            $expiresAt === '' ? 'NULL' : "'" . $db->escape($expiresAt) . "'",
108            $db->now(),
109        );
110
111        if (!$db->query($insert)) {
112            return $this->json(['error' => $db->error()], Response::HTTP_INTERNAL_SERVER_ERROR);
113        }
114
115        return $this->json([
116            'id' => $id,
117            'apiKey' => $apiKey,
118            'name' => $name,
119            'scopes' => $scopes,
120            'expiresAt' => $expiresAt !== '' ? $expiresAt : null,
121        ], Response::HTTP_CREATED);
122    }
123
124    /**
125     * @throws \Exception
126     * @throws JsonException
127     */
128    #[Route(path: 'user/api-keys/{id}', name: 'admin.api.user.api-keys.update', methods: ['PUT'])]
129    public function update(Request $request): JsonResponse
130    {
131        $this->userHasPermission(PermissionType::USER_EDIT);
132
133        $data = json_decode(json: $request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
134        $csrf = Filter::filterVar($data->csrf ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
135        if (!$this->verifySessionCsrfToken('api-key-update', $csrf)) {
136            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
137        }
138
139        if ($request->attributes->get('id') === null) {
140            return $this->json(['error' => 'API key ID is required.'], Response::HTTP_BAD_REQUEST);
141        }
142
143        $id = (int) $request->attributes->get('id');
144        $name = Filter::filterVar($data->name ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
145        if ($name === '') {
146            return $this->json(['error' => 'API key name is required.'], Response::HTTP_BAD_REQUEST);
147        }
148
149        $scopes = is_array($data->scopes ?? null) ? array_values($data->scopes) : [];
150        $scopes = array_values(array_filter($scopes, is_string(...)));
151        $expiresAt = Filter::filterVar($data->expiresAt ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
152
153        if ($expiresAt !== '' && strtotime($expiresAt) === false) {
154            return $this->json(['error' => 'Invalid expiresAt value.'], Response::HTTP_BAD_REQUEST);
155        }
156
157        $db = $this->configuration->getDb();
158
159        $check = sprintf(
160            'SELECT id FROM %sfaqapi_keys WHERE id = %d AND user_id = %d',
161            Database::getTablePrefix(),
162            $id,
163            $this->currentUser->getUserId(),
164        );
165        $checkResult = $db->query($check);
166        if ($checkResult === false || $db->numRows($checkResult) === 0) {
167            return $this->json(['error' => 'API key not found.'], Response::HTTP_NOT_FOUND);
168        }
169
170        $update = sprintf(
171            "UPDATE %sfaqapi_keys
172             SET name = '%s', scopes = '%s', expires_at = %s
173             WHERE id = %d AND user_id = %d",
174            Database::getTablePrefix(),
175            $db->escape($name),
176            $db->escape((string) json_encode($scopes)),
177            $expiresAt === '' ? 'NULL' : "'" . $db->escape($expiresAt) . "'",
178            $id,
179            $this->currentUser->getUserId(),
180        );
181
182        if (!$db->query($update)) {
183            return $this->json(['error' => $db->error()], Response::HTTP_INTERNAL_SERVER_ERROR);
184        }
185
186        return $this->json([
187            'id' => $id,
188            'name' => $name,
189            'scopes' => $scopes,
190            'expiresAt' => $expiresAt !== '' ? $expiresAt : null,
191        ], Response::HTTP_OK);
192    }
193
194    /**
195     * @throws \Exception
196     */
197    #[Route(path: 'user/api-keys/{id}', name: 'admin.api.user.api-keys.delete', methods: ['DELETE'])]
198    public function delete(Request $request): JsonResponse
199    {
200        $this->userHasPermission(PermissionType::USER_EDIT);
201
202        $csrf = $request->headers->get('X-CSRF-Token') ?? $request->query->get('csrf');
203
204        if ($csrf === null) {
205            $body = json_decode(json: $request->getContent(), associative: false);
206            $csrf = $body->csrf ?? null;
207        }
208
209        $csrf = Filter::filterVar((string) ($csrf ?? ''), FILTER_SANITIZE_SPECIAL_CHARS, '');
210        if (!$this->verifySessionCsrfToken('api-key-delete', $csrf)) {
211            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
212        }
213
214        if ($request->attributes->get('id') === null) {
215            return $this->json(['error' => 'API key ID is required.'], Response::HTTP_BAD_REQUEST);
216        }
217
218        $id = (int) $request->attributes->get('id');
219        $db = $this->configuration->getDb();
220        $delete = sprintf(
221            'DELETE FROM %sfaqapi_keys WHERE id = %d AND user_id = %d',
222            Database::getTablePrefix(),
223            $id,
224            $this->currentUser->getUserId(),
225        );
226
227        if (!$db->query($delete)) {
228            return $this->json(['error' => $db->error()], Response::HTTP_INTERNAL_SERVER_ERROR);
229        }
230
231        return $this->json(['success' => true], Response::HTTP_OK);
232    }
233}