Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttachmentController
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
4.00
0.00% covered (danger)
0.00%
0 / 1
 list
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2
3/**
4 * The Attachment Controller for the REST API
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 2023-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     2023-07-30
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Api;
21
22use LogicException;
23use OpenApi\Attributes as OA;
24use phpMyFAQ\Attachment\AttachmentException;
25use phpMyFAQ\Attachment\AttachmentFactory;
26use phpMyFAQ\Faq;
27use phpMyFAQ\Filter;
28use phpMyFAQ\User\CurrentUser;
29use Symfony\Component\HttpFoundation\JsonResponse;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\HttpFoundation\Response;
32use Symfony\Component\Routing\Attribute\Route;
33
34final class AttachmentController extends AbstractApiController
35{
36    #[OA\Get(
37        path: '/api/v4.0/attachments/{faqId}',
38        operationId: 'getAttachments',
39        description: 'Returns a paginated list of attachments for a given FAQ record ID with optional sorting.',
40        tags: ['Public Endpoints'],
41    )]
42    #[OA\Header(
43        header: 'Accept-Language',
44        description: 'The language code for the attachment.',
45        schema: new OA\Schema(type: 'string'),
46    )]
47    #[OA\Parameter(
48        name: 'faqId',
49        description: 'The FAQ record ID.',
50        in: 'path',
51        required: true,
52        schema: new OA\Schema(type: 'integer'),
53    )]
54    #[OA\Parameter(
55        name: 'page',
56        description: 'Page number for pagination (1-indexed)',
57        in: 'query',
58        required: false,
59        schema: new OA\Schema(type: 'integer', default: 1, minimum: 1),
60    )]
61    #[OA\Parameter(
62        name: 'per_page',
63        description: 'Number of items per page',
64        in: 'query',
65        required: false,
66        schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1),
67    )]
68    #[OA\Parameter(
69        name: 'limit',
70        description: 'Alternative to per_page for offset-based pagination',
71        in: 'query',
72        required: false,
73        schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1),
74    )]
75    #[OA\Parameter(
76        name: 'offset',
77        description: 'Offset for pagination (overrides page parameter)',
78        in: 'query',
79        required: false,
80        schema: new OA\Schema(type: 'integer', default: 0, minimum: 0),
81    )]
82    #[OA\Parameter(
83        name: 'sort',
84        description: 'Field to sort by',
85        in: 'query',
86        required: false,
87        schema: new OA\Schema(type: 'string', enum: ['id', 'filename', 'mime_type', 'filesize', 'created']),
88    )]
89    #[OA\Parameter(
90        name: 'order',
91        description: 'Sort order',
92        in: 'query',
93        required: false,
94        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
95    )]
96    #[OA\Response(
97        response: 200,
98        description: 'Paginated list of attachments with metadata.',
99        content: new OA\JsonContent(example: [
100            'success' => true,
101            'data' => [
102                [
103                    'filename' => 'attachment-1.pdf',
104                    'url' => 'https://www.example.org/attachment/1',
105                ],
106                [
107                    'filename' => 'attachment-2.pdf',
108                    'url' => 'https://www.example.org/attachment/2',
109                ],
110            ],
111            'meta' => [
112                'pagination' => [
113                    'total' => 2,
114                    'count' => 2,
115                    'per_page' => 25,
116                    'current_page' => 1,
117                    'total_pages' => 1,
118                    'offset' => 0,
119                    'has_more' => false,
120                    'has_previous' => false,
121                    'links' => [
122                        'first' => '/api/v4.0/attachments/1?page=1&per_page=25',
123                        'last' => '/api/v4.0/attachments/1?page=1&per_page=25',
124                        'prev' => null,
125                        'next' => null,
126                    ],
127                ],
128                'sorting' => [
129                    'field' => 'filename',
130                    'order' => 'asc',
131                ],
132            ],
133        ]),
134    )]
135    #[OA\Response(
136        response: 500,
137        description: 'If the attachments cannot be fetched.',
138        content: new OA\JsonContent(example: [
139            'success' => false,
140            'error' => [
141                'code' => 'ATTACHMENT_ERROR',
142                'message' => 'Failed to fetch attachments',
143            ],
144        ]),
145    )]
146    #[Route(path: 'v4.0/attachments/{faqId}', name: 'api.attachments', methods: ['GET'])]
147    public function list(Request $request): JsonResponse
148    {
149        $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT);
150
151        // Get pagination and sorting parameters
152        $pagination = $this->getPaginationRequest($request);
153        $sort = $this->getSortRequest(
154            $request,
155            allowedFields: ['id', 'filename', 'mime_type', 'filesize', 'created'],
156            defaultField: 'id',
157            defaultOrder: 'asc',
158        );
159
160        // Do not disclose attachment metadata of a FAQ record the requester is not allowed to see.
161        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
162        $faq = $this->container->get(id: 'phpmyfaq.faq');
163        if (!$faq instanceof Faq) {
164            throw new LogicException('Faq service not found in container.');
165        }
166
167        $faq->setUser($currentUser);
168        $faq->setGroups($currentGroups);
169
170        if (!$faq->isFaqAccessibleForUser($faqId)) {
171            return $this->json([], Response::HTTP_NOT_FOUND);
172        }
173
174        try {
175            // Fetch paginated attachments using property access (PHP 8.4 property hooks)
176            $attachments = AttachmentFactory::fetchByRecordIdPaginated(
177                configuration: $this->configuration,
178                recordId: $faqId,
179                limit: $pagination->limit,
180                offset: $pagination->offset,
181                sortField: $sort->getField() ?? 'id',
182                sortOrder: $sort->getOrderSql(),
183            );
184
185            // Get total count for pagination metadata
186            $total = AttachmentFactory::countByRecordId($this->configuration, $faqId);
187
188            // Return paginated response with envelope
189            return $this->paginatedResponse(
190                $request,
191                data: $attachments,
192                total: $total,
193                pagination: $pagination,
194                options: new PaginatedResponseOptions(sort: $sort),
195            );
196        } catch (AttachmentException) {
197            return $this->errorResponse(
198                message: 'Failed to fetch attachments',
199                code: 'ATTACHMENT_ERROR',
200                status: Response::HTTP_INTERNAL_SERVER_ERROR,
201            );
202        }
203    }
204}