Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
260 / 280
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageController
92.86% covered (success)
92.86%
260 / 280
50.00% covered (danger)
50.00%
5 / 10
81.27
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
 indexCustomPage
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 updateCustomPageIndex
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 deleteCustomPageFromIndex
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 create
90.24% covered (success)
90.24%
74 / 82
0.00% covered (danger)
0.00%
0 / 1
23.49
 delete
89.47% covered (success)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 update
92.06% covered (success)
92.06%
58 / 63
0.00% covered (danger)
0.00%
0 / 1
15.11
 activate
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
10.10
 checkSlug
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
7.04
 list
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Admin Page API 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-01-15
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use DateTime;
23use Exception;
24use phpMyFAQ\CustomPage;
25use phpMyFAQ\Entity\CustomPageEntity;
26use phpMyFAQ\Enums\AdminLogType;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Filter;
29use phpMyFAQ\Instance\Search\Elasticsearch;
30use phpMyFAQ\Instance\Search\OpenSearch;
31use phpMyFAQ\Session\Token;
32use phpMyFAQ\Translation;
33use stdClass;
34use Symfony\Component\HttpFoundation\JsonResponse;
35use Symfony\Component\HttpFoundation\Request;
36use Symfony\Component\HttpFoundation\Response;
37use Symfony\Component\Routing\Attribute\Route;
38
39final class PageController extends AbstractAdministrationApiController
40{
41    public function __construct(
42        private readonly Elasticsearch $elasticsearch,
43        private readonly OpenSearch $openSearch,
44    ) {
45        parent::__construct();
46    }
47
48    /**
49     * Index a custom page in Elasticsearch and OpenSearch
50     *
51     * @param array<string, mixed> $pageData
52     * @throws Exception
53     */
54    private function indexCustomPage(array $pageData): void
55    {
56        // Index in Elasticsearch if enabled
57        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
58            try {
59                $this->elasticsearch->indexCustomPage($pageData);
60            } catch (Exception $e) {
61                $this->configuration->getLogger()->error('Failed to index custom page in Elasticsearch', [
62                    'error' => $e->getMessage(),
63                    'page_id' => $pageData['id'] ?? null,
64                ]);
65            }
66        }
67
68        // Index in OpenSearch if enabled
69        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
70            try {
71                $this->openSearch->indexCustomPage($pageData);
72            } catch (Exception $e) {
73                $this->configuration->getLogger()->error('Failed to index custom page in OpenSearch', [
74                    'error' => $e->getMessage(),
75                    'page_id' => $pageData['id'] ?? null,
76                ]);
77            }
78        }
79    }
80
81    /**
82     * Update a custom page in Elasticsearch and OpenSearch
83     *
84     * @param array<string, mixed> $pageData
85     * @throws Exception
86     */
87    private function updateCustomPageIndex(array $pageData): void
88    {
89        // Update in Elasticsearch if enabled
90        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
91            try {
92                $this->elasticsearch->updateCustomPage($pageData);
93            } catch (Exception $e) {
94                $this->configuration->getLogger()->error('Failed to update custom page in Elasticsearch', [
95                    'error' => $e->getMessage(),
96                    'page_id' => $pageData['id'] ?? null,
97                ]);
98            }
99        }
100
101        // Update in OpenSearch if enabled
102        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
103            try {
104                $this->openSearch->updateCustomPage($pageData);
105            } catch (Exception $e) {
106                $this->configuration->getLogger()->error('Failed to update custom page in OpenSearch', [
107                    'error' => $e->getMessage(),
108                    'page_id' => $pageData['id'] ?? null,
109                ]);
110            }
111        }
112    }
113
114    /**
115     * Delete a custom page from Elasticsearch and OpenSearch
116     */
117    private function deleteCustomPageFromIndex(int $pageId, string $lang): void
118    {
119        // Delete it from Elasticsearch if enabled
120        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
121            try {
122                $this->elasticsearch->deleteCustomPage($pageId, $lang);
123            } catch (Exception $e) {
124                $this->configuration->getLogger()->error('Failed to delete custom page from Elasticsearch', [
125                    'error' => $e->getMessage(),
126                    'page_id' => $pageId,
127                ]);
128            }
129        }
130
131        // Delete from OpenSearch if enabled
132        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
133            try {
134                $this->openSearch->deleteCustomPage($pageId, $lang);
135            } catch (Exception $e) {
136                $this->configuration->getLogger()->error('Failed to delete custom page from OpenSearch', [
137                    'error' => $e->getMessage(),
138                    'page_id' => $pageId,
139                ]);
140            }
141        }
142    }
143
144    /**
145     * @throws Exception
146     */
147    #[Route(path: 'page/create', name: 'admin.api.page.create', methods: ['POST'])]
148    public function create(Request $request): JsonResponse
149    {
150        $this->userHasPermission(PermissionType::PAGE_ADD);
151
152        $data = json_decode($request->getContent());
153
154        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
155            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
156        }
157
158        $customPage = new CustomPage($this->configuration);
159
160        if (!Token::getInstance($this->session)->verifyToken(
161            page: 'save-page',
162            requestToken: (string) ($data->csrfToken ?? ''),
163        )) {
164            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
165        }
166
167        // Validate required fields
168        $requiredFields = ['pageTitle', 'slug', 'authorName', 'authorEmail', 'lang'];
169        foreach ($requiredFields as $field) {
170            if (!(($data->$field ?? null) === null || $data->$field === '')) {
171                continue;
172            }
173
174            return $this->json(['error' => "Missing required field: {$field}"], Response::HTTP_BAD_REQUEST);
175        }
176
177        $pageTitle = Filter::filterVar($data->pageTitle, FILTER_SANITIZE_SPECIAL_CHARS, '');
178        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
179        $content = Filter::filterVar($data->content ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
180        $authorName = Filter::filterVar($data->authorName, FILTER_SANITIZE_SPECIAL_CHARS, '');
181        $authorEmail = Filter::filterEmail($data->authorEmail);
182        if (!is_string($authorEmail) || $authorEmail === '') {
183            return $this->json(['error' => 'Missing required field: authorEmail'], Response::HTTP_BAD_REQUEST);
184        }
185        $active = Filter::filterVar($data->active ?? false, FILTER_SANITIZE_SPECIAL_CHARS);
186        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
187        $seoTitle = Filter::filterVar($data->seoTitle ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
188        $seoDescription = Filter::filterVar($data->seoDescription ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
189        $seoRobots = Filter::filterVar($data->seoRobots ?? 'index,follow', FILTER_SANITIZE_SPECIAL_CHARS, '');
190
191        // Check if this is a translation (pageId provided)
192        $rawPageId = $data->pageId ?? null;
193        $isTranslation = $rawPageId !== null && (int) $rawPageId > 0;
194        $translationPageId = $isTranslation ? Filter::filterVar($rawPageId, FILTER_VALIDATE_INT) : null;
195
196        // For translations, check if language already exists for this page ID
197        if ($isTranslation) {
198            if ($translationPageId === null) {
199                return $this->json([
200                    'error' => Translation::get(key: 'ad_page_insertfail'),
201                ], Response::HTTP_BAD_REQUEST);
202            }
203
204            $existingLanguages = $customPage->getExistingLanguages($translationPageId);
205            if (in_array($language, $existingLanguages, strict: true)) {
206                return $this->json([
207                    'error' => 'Translation for this language already exists',
208                ], Response::HTTP_CONFLICT);
209            }
210        }
211
212        // Check if slug exists
213        if ($customPage->slugExists($slug, $language)) {
214            return $this->json(['error' => Translation::get(key: 'ad_page_slug_exists')], Response::HTTP_CONFLICT);
215        }
216
217        $pageEntity = new CustomPageEntity();
218        $pageEntity
219            ->setLanguage($language)
220            ->setPageTitle($pageTitle)
221            ->setSlug($slug)
222            ->setContent(Filter::removeAttributes(html_entity_decode(
223                (string) $content,
224                ENT_QUOTES | ENT_HTML5,
225                encoding: 'UTF-8',
226            )))
227            ->setAuthorName($authorName)
228            ->setAuthorEmail($authorEmail)
229            ->setActive((bool) $active)
230            ->setSeoTitle($seoTitle !== '' ? $seoTitle : null)
231            ->setSeoDescription($seoDescription !== '' ? $seoDescription : null)
232            ->setSeoRobots($seoRobots)
233            ->setCreated(new DateTime());
234
235        // Create a translation or new page
236        $success = false;
237        $pageId = 0;
238        if ($isTranslation && $translationPageId !== null) {
239            $success = $customPage->createTranslation($pageEntity, $translationPageId);
240            $pageId = $success ? $translationPageId : 0;
241        }
242
243        if (!$isTranslation) {
244            $pageId = $customPage->create($pageEntity);
245            $success = $pageId > 0;
246        }
247
248        if ($success) {
249            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_ADD->value);
250
251            // Index in Elasticsearch/OpenSearch
252            $this->indexCustomPage([
253                'id' => $pageId,
254                'lang' => $language,
255                'page_title' => $pageTitle,
256                'content' => $pageEntity->getContent(),
257                'slug' => $slug,
258                'active' => $active ? 'y' : 'n',
259            ]);
260
261            return $this->json([
262                'success' => Translation::get(key: 'ad_page_updatesuc'),
263                'id' => $pageId,
264            ], Response::HTTP_OK);
265        }
266
267        return $this->json(['error' => Translation::get(key: 'ad_page_insertfail')], Response::HTTP_BAD_GATEWAY);
268    }
269
270    /**
271     * @throws Exception
272     */
273    #[Route(path: 'page/delete', name: 'admin.api.page.delete', methods: ['DELETE'])]
274    public function delete(Request $request): JsonResponse
275    {
276        $this->userHasPermission(PermissionType::PAGE_DELETE);
277
278        $data = json_decode($request->getContent());
279
280        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
281            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
282        }
283
284        $customPage = new CustomPage($this->configuration);
285
286        if (!Token::getInstance($this->session)->verifyToken(
287            page: 'delete-page',
288            requestToken: (string) ($data->csrfToken ?? ''),
289        )) {
290            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
291        }
292
293        // Validate required fields
294        if (($data->id ?? null) === null || ($data->lang ?? null) === null) {
295            return $this->json(['error' => 'Missing required fields: id, lang'], Response::HTTP_BAD_REQUEST);
296        }
297
298        $deleteId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
299        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
300
301        if ($customPage->delete((int) $deleteId, $language)) {
302            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_DELETE->value . ':' . (int) $deleteId);
303
304            // Delete from Elasticsearch/OpenSearch
305            $this->deleteCustomPageFromIndex((int) $deleteId, $language);
306
307            return $this->json(['success' => Translation::get(key: 'ad_page_delsuc')], Response::HTTP_OK);
308        }
309
310        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
311    }
312
313    /**
314     * @throws Exception
315     */
316    #[Route(path: 'page/update', name: 'admin.api.page.update', methods: ['PUT'])]
317    public function update(Request $request): JsonResponse
318    {
319        $this->userHasPermission(PermissionType::PAGE_EDIT);
320
321        $data = json_decode($request->getContent());
322
323        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
324            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
325        }
326
327        $customPage = new CustomPage($this->configuration);
328
329        if (!Token::getInstance($this->session)->verifyToken(
330            page: 'update-page',
331            requestToken: (string) ($data->csrfToken ?? ''),
332        )) {
333            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
334        }
335
336        // Validate required fields
337        $requiredFields = ['id', 'pageTitle', 'slug', 'authorName', 'authorEmail', 'lang'];
338        foreach ($requiredFields as $field) {
339            if (!(($data->$field ?? null) === null || $data->$field === '')) {
340                continue;
341            }
342
343            return $this->json(['error' => "Missing required field: {$field}"], Response::HTTP_BAD_REQUEST);
344        }
345
346        $pageId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
347
348        if ($pageId === null) {
349            return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_REQUEST);
350        }
351
352        $pageTitle = Filter::filterVar($data->pageTitle, FILTER_SANITIZE_SPECIAL_CHARS, '');
353        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
354        $content = Filter::filterVar($data->content ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
355        $authorName = Filter::filterVar($data->authorName, FILTER_SANITIZE_SPECIAL_CHARS, '');
356        $authorEmail = Filter::filterEmail($data->authorEmail);
357        if (!is_string($authorEmail) || $authorEmail === '') {
358            return $this->json(['error' => 'Missing required field: authorEmail'], Response::HTTP_BAD_REQUEST);
359        }
360        $active = Filter::filterVar($data->active ?? false, FILTER_SANITIZE_SPECIAL_CHARS);
361        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
362        $seoTitle = Filter::filterVar($data->seoTitle ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
363        $seoDescription = Filter::filterVar($data->seoDescription ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
364        $seoRobots = Filter::filterVar($data->seoRobots ?? 'index,follow', FILTER_SANITIZE_SPECIAL_CHARS, '');
365
366        // Check if slug exists (excluding current page)
367        if ($customPage->slugExists($slug, $language, $pageId)) {
368            return $this->json(['error' => Translation::get(key: 'ad_page_slug_exists')], Response::HTTP_CONFLICT);
369        }
370
371        $pageEntity = new CustomPageEntity();
372        $pageEntity
373            ->setId($pageId)
374            ->setLanguage($language)
375            ->setPageTitle($pageTitle)
376            ->setSlug($slug)
377            ->setContent(Filter::removeAttributes(html_entity_decode(
378                (string) $content,
379                ENT_QUOTES | ENT_HTML5,
380                encoding: 'UTF-8',
381            )))
382            ->setAuthorName($authorName)
383            ->setAuthorEmail($authorEmail)
384            ->setActive((bool) $active)
385            ->setSeoTitle($seoTitle !== '' ? $seoTitle : null)
386            ->setSeoDescription($seoDescription !== '' ? $seoDescription : null)
387            ->setSeoRobots($seoRobots)
388            ->setCreated(new DateTime())
389            ->setUpdated(new DateTime());
390
391        if ($customPage->update($pageEntity)) {
392            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_EDIT->value . ':' . $pageId);
393
394            // Update in Elasticsearch/OpenSearch
395            $this->updateCustomPageIndex([
396                'id' => $pageId,
397                'lang' => $language,
398                'page_title' => $pageTitle,
399                'content' => $pageEntity->getContent(),
400                'slug' => $slug,
401                'active' => $active ? 'y' : 'n',
402            ]);
403
404            return $this->json(['success' => Translation::get(key: 'ad_page_updatesuc')], Response::HTTP_OK);
405        }
406
407        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
408    }
409
410    /**
411     * @throws Exception
412     */
413    #[Route(path: 'page/activate', name: 'admin.api.page.activate', methods: ['PUT'])]
414    public function activate(Request $request): JsonResponse
415    {
416        $this->userHasPermission(PermissionType::PAGE_EDIT);
417        $data = json_decode($request->getContent());
418
419        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
420            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
421        }
422
423        $customPage = new CustomPage($this->configuration);
424
425        if (!Token::getInstance($this->session)->verifyToken(
426            page: 'activate-page',
427            requestToken: (string) ($data->csrfToken ?? ''),
428        )) {
429            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
430        }
431
432        // Validate required fields
433        if (($data->id ?? null) === null || ($data->status ?? null) === null) {
434            return $this->json(['error' => 'Missing required fields: id, status'], Response::HTTP_BAD_REQUEST);
435        }
436
437        $pageId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
438        $status = (bool) Filter::filterVar($data->status, FILTER_SANITIZE_SPECIAL_CHARS);
439
440        if ($pageId === null) {
441            return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_REQUEST);
442        }
443
444        if ($customPage->activate($pageId, $status)) {
445            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_EDIT->value . ':' . $pageId);
446
447            // Get page data for indexing
448            $pageEntity = $customPage->getById($pageId);
449            if ($pageEntity) {
450                // Update in Elasticsearch/OpenSearch with new active status
451                $this->updateCustomPageIndex([
452                    'id' => $pageId,
453                    'lang' => $pageEntity->getLanguage(),
454                    'page_title' => $pageEntity->getPageTitle(),
455                    'content' => $pageEntity->getContent(),
456                    'slug' => $pageEntity->getSlug(),
457                    'active' => $status ? 'y' : 'n',
458                ]);
459            }
460
461            return $this->json(['success' => Translation::get(key: 'ad_page_updatesuc')], Response::HTTP_OK);
462        }
463
464        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
465    }
466
467    /**
468     * Check if a slug is available
469     *
470     * @throws Exception
471     */
472    #[Route(path: 'page/check-slug', name: 'admin.api.page.check-slug', methods: ['POST'])]
473    public function checkSlug(Request $request): JsonResponse
474    {
475        $this->userHasPermission(PermissionType::PAGE_ADD);
476
477        $data = json_decode($request->getContent());
478
479        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
480            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
481        }
482
483        $customPage = new CustomPage($this->configuration);
484
485        if (!Token::getInstance($this->session)->verifyToken(
486            page: 'save-page',
487            requestToken: (string) ($data->csrfToken ?? ''),
488        )) {
489            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
490        }
491
492        // Validate required fields
493        if (($data->slug ?? null) === null || ($data->lang ?? null) === null) {
494            return $this->json(['error' => 'Missing required fields: slug, lang'], Response::HTTP_BAD_REQUEST);
495        }
496
497        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
498        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
499        $excludeId = ($data->excludeId ?? null) !== null
500            ? Filter::filterVar($data->excludeId, FILTER_VALIDATE_INT)
501            : null;
502
503        $exists = $customPage->slugExists($slug, $language, $excludeId);
504
505        return $this->json([
506            'available' => !$exists,
507            'slug' => $slug,
508        ], Response::HTTP_OK);
509    }
510
511    /**
512     * Get a paginated list of pages
513     *
514     * @throws Exception
515     */
516    #[Route(path: 'page/list', name: 'admin.api.page.list', methods: ['GET'])]
517    public function list(Request $request): JsonResponse
518    {
519        $this->userHasPermission(PermissionType::PAGE_EDIT);
520
521        $customPage = new CustomPage($this->configuration);
522
523        $limit = Filter::filterVar($request->query->get('limit'), FILTER_VALIDATE_INT, 25);
524        $offset = Filter::filterVar($request->query->get('offset'), FILTER_VALIDATE_INT, 0);
525        $sortField = Filter::filterVar($request->query->get('sortField'), FILTER_SANITIZE_SPECIAL_CHARS, 'created');
526        $sortOrder = Filter::filterVar($request->query->get('sortOrder'), FILTER_SANITIZE_SPECIAL_CHARS, 'DESC');
527        $activeOnly = (bool) Filter::filterVar($request->query->get('activeOnly'), FILTER_VALIDATE_BOOLEAN, false);
528
529        $pages = $customPage->getPagesPaginated(
530            activeOnly: $activeOnly,
531            limit: $limit,
532            offset: $offset,
533            sortField: $sortField,
534            sortOrder: $sortOrder,
535        );
536
537        $total = $customPage->countPages(activeOnly: $activeOnly);
538
539        return $this->json([
540            'data' => $pages,
541            'total' => $total,
542            'limit' => $limit,
543            'offset' => $offset,
544        ], Response::HTTP_OK);
545    }
546}