Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.96% covered (warning)
65.96%
62 / 94
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttachmentController
65.96% covered (warning)
65.96%
62 / 94
0.00% covered (danger)
0.00%
0 / 3
49.66
0.00% covered (danger)
0.00%
0 / 1
 delete
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
5.76
 refresh
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
5.68
 upload
63.79% covered (warning)
63.79%
37 / 58
0.00% covered (danger)
0.00%
0 / 1
25.68
1<?php
2
3/**
4 * The Admin Attachment 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 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-10-26
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use phpMyFAQ\Attachment\AttachmentException;
23use phpMyFAQ\Attachment\AttachmentFactory;
24use phpMyFAQ\Attachment\Filename;
25use phpMyFAQ\Attachment\Filesystem\File\FileException;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Enums\AdminLogType;
28use phpMyFAQ\Enums\PermissionType;
29use phpMyFAQ\Filter;
30use phpMyFAQ\Translation;
31use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
32use Symfony\Component\HttpFoundation\File\UploadedFile;
33use Symfony\Component\HttpFoundation\JsonResponse;
34use Symfony\Component\HttpFoundation\Request;
35use Symfony\Component\HttpFoundation\Response;
36use Symfony\Component\Routing\Attribute\Route;
37
38final class AttachmentController extends AbstractAdministrationApiController
39{
40    /**
41     * @throws \Exception
42     */
43    #[Route(path: 'content/attachments', name: 'admin.api.content.attachments', methods: ['DELETE'])]
44    public function delete(Request $request): JsonResponse
45    {
46        $this->userHasPermission(PermissionType::ATTACHMENT_DELETE);
47
48        $deleteData = $this->getJsonObject($request);
49        try {
50            if (!$this->verifySessionCsrfToken('delete-attachment', (string) ($deleteData->csrf ?? ''))) {
51                return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
52            }
53
54            $attId = Filter::filterVar($deleteData->attId ?? null, FILTER_VALIDATE_INT);
55            if (!$attId) {
56                return $this->json(['error' => 'Invalid attachment ID'], Response::HTTP_BAD_REQUEST);
57            }
58
59            $attId = (int) $attId;
60            $attachment = AttachmentFactory::create($attId);
61            if ($attachment->delete()) {
62                $this->adminLog->log($this->currentUser, AdminLogType::ATTACHMENT_DELETE->value . ':' . $attId);
63
64                return $this->json(['success' => Translation::get(key: 'msgAttachmentsDeleted')], Response::HTTP_OK);
65            }
66
67            return $this->json(['error' => Translation::get(key: 'ad_att_delfail')], Response::HTTP_BAD_REQUEST);
68        } catch (AttachmentException $attachmentException) {
69            $result = ['error' => $attachmentException->getMessage()];
70            return $this->json($result, Response::HTTP_INTERNAL_SERVER_ERROR);
71        }
72    }
73
74    /**
75     * @throws \Exception
76     */
77    #[Route(path: 'content/attachments/refresh', name: 'admin.api.content.attachments.refresh', methods: ['POST'])]
78    public function refresh(Request $request): JsonResponse
79    {
80        $this->userHasPermission(PermissionType::ATTACHMENT_DELETE);
81
82        $dataToCheck = $this->getJsonObject($request);
83        try {
84            if (!$this->verifySessionCsrfToken('refresh-attachment', (string) ($dataToCheck->csrf ?? ''))) {
85                return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
86            }
87
88            $attId = Filter::filterVar($dataToCheck->attId ?? null, FILTER_VALIDATE_INT);
89            if (!$attId) {
90                return $this->json(['error' => 'Invalid attachment ID'], Response::HTTP_BAD_REQUEST);
91            }
92
93            $attId = (int) $attId;
94            $attachment = AttachmentFactory::create($attId);
95            $result = [
96                'success' => Translation::get(key: 'msgAdminAttachmentRefreshed'),
97                'delete' => false,
98            ];
99            if (!$attachment->isStorageOk()) {
100                $attachment->deleteMeta();
101                $result = ['success' => Translation::get(key: 'ad_att_delsuc'), 'delete' => true];
102            }
103
104            return $this->json($result, Response::HTTP_OK);
105        } catch (AttachmentException $attachmentException) {
106            $result = ['error' => $attachmentException->getMessage()];
107            return $this->json($result, Response::HTTP_INTERNAL_SERVER_ERROR);
108        }
109    }
110
111    /**
112     * @throws AttachmentException
113     * @throws FileException
114     * @throws Exception
115     * @throws \Exception
116     */
117    #[Route(path: 'content/attachments/upload', name: 'admin.api.content.attachments.upload', methods: ['POST'])]
118    public function upload(Request $request): JsonResponse
119    {
120        $this->userHasPermission(PermissionType::ATTACHMENT_ADD);
121
122        $csrfToken = (string) Filter::filterVar(
123            $request->request->get('pmf-csrf-token'),
124            FILTER_SANITIZE_SPECIAL_CHARS,
125        );
126        if (!$this->verifySessionCsrfToken('upload-attachment', $csrfToken)) {
127            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
128        }
129
130        $files = $request->files->get('filesToUpload');
131
132        if (!$files) {
133            return $this->json(['error' => Translation::get(key: 'msgNoImagesForUpload')], Response::HTTP_BAD_REQUEST);
134        }
135
136        $files = is_array($files) ? $files : [$files];
137        $uploadedFiles = [];
138        $customFileNames = $request->request->all('customFileNames');
139
140        foreach ($files as $index => $file) {
141            if (!$file instanceof UploadedFile) {
142                return $this->json([
143                    'error' => Translation::get(key: 'msgNoImagesForUpload'),
144                ], Response::HTTP_BAD_REQUEST);
145            }
146
147            if (
148                !$file->isValid()
149                || (int) $file->getSize() > (int) $this->configuration->get(item: 'records.maxAttachmentSize')
150                || $file->getMimeType() === 'text/html'
151            ) {
152                return $this->json(['error' => Translation::get(key: 'msgImageTooLarge')], Response::HTTP_BAD_REQUEST);
153            }
154
155            $recordId = Filter::filterVar($request->request->get('record_id'), FILTER_VALIDATE_INT);
156
157            if ($recordId === null) {
158                return $this->json([
159                    'error' => Translation::get(key: 'msgNoImagesForUpload'),
160                ], Response::HTTP_BAD_REQUEST);
161            }
162
163            $attachment = AttachmentFactory::create();
164            $attachment->setRecordId((int) $recordId);
165            $attachment->setRecordLang((string) Filter::filterVar(
166                $request->request->get('record_lang'),
167                FILTER_SANITIZE_SPECIAL_CHARS,
168                '',
169            ));
170            try {
171                $customFileName = array_key_exists($index, $customFileNames) ? $customFileNames[$index] : null;
172                $filename = Filename::compose(
173                    $file->getClientOriginalName(),
174                    is_string($customFileName) ? $customFileName : null,
175                );
176                if (!$attachment->save($file->getPathname(), $filename)) {
177                    return $this->json([
178                        'error' => Translation::get('msgImageCouldNotBeUploaded'),
179                    ], Response::HTTP_INTERNAL_SERVER_ERROR);
180                }
181            } catch (AttachmentException|FileNotFoundException $exception) {
182                return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
183            }
184
185            $uploadedFiles[] = [
186                'attachmentId' => $attachment->getId(),
187                'fileName' => $attachment->getFilename(),
188                'faqId' => $request->request->get('record_id'),
189                'faqLanguage' => $request->request->get('record_lang'),
190            ];
191        }
192
193        if ($uploadedFiles !== []) {
194            $attachmentIds = array_column($uploadedFiles, 'attachmentId');
195            $this->adminLog->log(
196                $this->currentUser,
197                AdminLogType::ATTACHMENT_ADD->value . ':' . implode(',', $attachmentIds),
198            );
199        }
200
201        return $this->json($uploadedFiles, Response::HTTP_OK);
202    }
203}