Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.14% covered (warning)
74.14%
86 / 116
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportController
74.14% covered (warning)
74.14%
86 / 116
40.00% covered (danger)
40.00%
2 / 5
74.98
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
 exportFile
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
4.50
 exportReport
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
16.13
 buildReportResponse
63.16% covered (warning)
63.16%
36 / 57
0.00% covered (danger)
0.00%
0 / 1
40.00
 hasDataField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The File Export 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-12-23
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use JsonException;
23use League\CommonMark\Exception\CommonMarkException;
24use phpMyFAQ\Administration\FileDownloader;
25use phpMyFAQ\Administration\Report;
26use phpMyFAQ\Category;
27use phpMyFAQ\Controller\AbstractController;
28use phpMyFAQ\Core\Exception;
29use phpMyFAQ\Enums\PermissionType;
30use phpMyFAQ\Export;
31use phpMyFAQ\Faq;
32use phpMyFAQ\Filter;
33use phpMyFAQ\Language\LanguageCodes;
34use phpMyFAQ\Link\Util\TitleSlugifier;
35use phpMyFAQ\Session\Token;
36use phpMyFAQ\Translation;
37use Symfony\Component\HttpFoundation\HeaderUtils;
38use Symfony\Component\HttpFoundation\Request;
39use Symfony\Component\HttpFoundation\Response;
40use Symfony\Component\Routing\Attribute\Route;
41
42final class ExportController extends AbstractController
43{
44    public function __construct(
45        private readonly Faq $faq,
46    ) {
47        parent::__construct();
48    }
49
50    /**
51     * @throws \Exception
52     */
53    #[Route(path: 'export/file', name: 'admin.api.export.file', methods: ['POST'])]
54    public function exportFile(Request $request): Response
55    {
56        $this->userHasPermission(PermissionType::EXPORT);
57
58        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
59        if (!Token::getInstance($this->session)->verifyToken('export', $csrfToken)) {
60            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
61        }
62
63        $categoryId = (int) Filter::filterVar($request->request->get('categoryId'), FILTER_VALIDATE_INT);
64        $downwards = Filter::filterVar($request->request->get('downwards'), FILTER_VALIDATE_BOOLEAN, false);
65        $inlineDisposition = Filter::filterVar($request->request->get('disposition'), FILTER_SANITIZE_SPECIAL_CHARS);
66        $type = Filter::filterVar($request->request->get('export-type'), FILTER_SANITIZE_SPECIAL_CHARS, 'none');
67
68        $category = new Category($this->configuration, [], false);
69        $category->buildCategoryTree($categoryId);
70
71        try {
72            $export = Export::create($this->faq, $category, $this->configuration, $type);
73            $content = $export->generate($categoryId, $downwards, $this->configuration->getLanguage()->getLanguage());
74
75            // Build the streaming response so the HTTP kernel can send it
76            $httpStreamer = new FileDownloader($type, $content);
77            $disposition = 'inline' === $inlineDisposition
78                ? HeaderUtils::DISPOSITION_INLINE
79                : HeaderUtils::DISPOSITION_ATTACHMENT;
80
81            return $httpStreamer->getResponse($disposition);
82        } catch (Exception|JsonException|CommonMarkException $exception) {
83            return new Response($exception->getMessage(), Response::HTTP_BAD_REQUEST);
84        }
85    }
86
87    /**
88     * @throws \Exception
89     */
90    #[Route(path: 'export/report', name: 'admin.api.export.report', methods: ['POST'])]
91    public function exportReport(Request $request): Response
92    {
93        $this->userHasPermission(PermissionType::REPORTS);
94
95        $data = $this->getJsonObject($request)->data ?? null;
96        if (!$data instanceof \stdClass) {
97            return $this->json(['error' => 'The request body must contain a data object.'], Response::HTTP_BAD_REQUEST);
98        }
99
100        if (!Token::getInstance($this->session)->verifyToken(
101            'create-report',
102            (string) ($data->{'pmf-csrf-token'} ?? ''),
103        )) {
104            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
105        }
106
107        $text = [];
108        $text[0] = [];
109        if ($this->hasDataField(payload: $data, field: 'category')) {
110            $text[0][] = Translation::get(key: 'ad_stat_report_category');
111        }
112
113        if ($this->hasDataField(payload: $data, field: 'sub_category')) {
114            $text[0][] = Translation::get(key: 'ad_stat_report_sub_category');
115        }
116
117        if ($this->hasDataField(payload: $data, field: 'translations')) {
118            $text[0][] = Translation::get(key: 'ad_stat_report_translations');
119        }
120
121        if ($this->hasDataField(payload: $data, field: 'language')) {
122            $text[0][] = Translation::get(key: 'ad_stat_report_language');
123        }
124
125        if ($this->hasDataField(payload: $data, field: 'id')) {
126            $text[0][] = Translation::get(key: 'ad_stat_report_id');
127        }
128
129        if ($this->hasDataField(payload: $data, field: 'sticky')) {
130            $text[0][] = Translation::get(key: 'ad_stat_report_sticky');
131        }
132
133        if ($this->hasDataField(payload: $data, field: 'title')) {
134            $text[0][] = Translation::get(key: 'ad_stat_report_title');
135        }
136
137        if ($this->hasDataField(payload: $data, field: 'creation_date')) {
138            $text[0][] = Translation::get(key: 'ad_stat_report_creation_date');
139        }
140
141        if ($this->hasDataField(payload: $data, field: 'owner')) {
142            $text[0][] = Translation::get(key: 'ad_stat_report_owner');
143        }
144
145        if ($this->hasDataField(payload: $data, field: 'last_modified_person')) {
146            $text[0][] = Translation::get(key: 'ad_stat_report_last_modified_person');
147        }
148
149        if ($this->hasDataField(payload: $data, field: 'url')) {
150            $text[0][] = Translation::get(key: 'ad_stat_report_url');
151        }
152
153        if ($this->hasDataField(payload: $data, field: 'visits')) {
154            $text[0][] = Translation::get(key: 'ad_stat_report_visits');
155        }
156
157        try {
158            return $this->buildReportResponse(new Report($this->configuration), $data, $text);
159        } catch (\Throwable $throwable) {
160            return $this->json(['error' => $throwable->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
161        }
162    }
163
164    /**
165     * Builds the CSV report response from the requested fields.
166     *
167     * @param array<int, list<mixed>> $text
168     */
169    private function buildReportResponse(Report $report, \stdClass $data, array $text): Response
170    {
171        foreach ($report->getReportingData() as $reportData) {
172            $i = (int) $reportData['faq_id'];
173            // Top-level categories have no parent; normalise the absent/NULL
174            // value to 0 so it is treated as "no parent" instead of being
175            // passed on as NULL.
176            $categoryParent = (int) ($reportData['category_parent'] ?? 0);
177            if (
178                $this->hasDataField(payload: $data, field: 'category') && array_key_exists('category_name', $reportData)
179            ) {
180                $text[$i][] = Report::sanitize($report->convertEncoding((string) ($reportData['category_name'] ?? '')));
181                if (0 !== $categoryParent) {
182                    $text[$i][] = Report::sanitize($categoryParent);
183                }
184            }
185
186            if ($this->hasDataField(payload: $data, field: 'sub_category')) {
187                $text[$i][] = 'n/a';
188                if (0 !== $categoryParent) {
189                    $text[$i][] = Report::sanitize($report->convertEncoding(
190                        (string) ($reportData['category_name'] ?? ''),
191                    ));
192                }
193            }
194
195            if ($this->hasDataField(payload: $data, field: 'translations')) {
196                $text[$i][] = $reportData['faq_translations'];
197            }
198
199            $faqLanguage = (string) ($reportData['faq_language'] ?? '');
200            if ($this->hasDataField(payload: $data, field: 'language') && LanguageCodes::get($faqLanguage) !== null) {
201                $text[$i][] = $report->convertEncoding(LanguageCodes::get($faqLanguage) ?? '');
202            }
203
204            if ($this->hasDataField(payload: $data, field: 'id')) {
205                $text[$i][] = $reportData['faq_id'];
206            }
207
208            if ($this->hasDataField(payload: $data, field: 'sticky')) {
209                $text[$i][] = $reportData['faq_sticky'];
210            }
211
212            if ($this->hasDataField(payload: $data, field: 'title')) {
213                $text[$i][] = Report::sanitize($report->convertEncoding((string) ($reportData['faq_question'] ?? '')));
214            }
215
216            if ($this->hasDataField(payload: $data, field: 'creation_date')) {
217                $text[$i][] = $reportData['faq_updated'];
218            }
219
220            if ($this->hasDataField(payload: $data, field: 'owner')) {
221                $text[$i][] = Report::sanitize($report->convertEncoding(
222                    (string) ($reportData['faq_org_author'] ?? ''),
223                ));
224            }
225
226            $text[$i][] = '';
227            if (
228                $this->hasDataField(payload: $data, field: 'last_modified_person')
229                && array_key_exists('faq_last_author', $reportData)
230            ) {
231                $text[$i][] = Report::sanitize($report->convertEncoding(
232                    (string) ($reportData['faq_last_author'] ?? ''),
233                ));
234            }
235
236            if ($this->hasDataField(payload: $data, field: 'url')) {
237                $text[$i][] = Report::sanitize($report->convertEncoding(sprintf(
238                    '%scontent/%d/%d/%s/%s.html',
239                    $this->configuration->getDefaultUrl(),
240                    (int) ($reportData['category_id'] ?? 0),
241                    (int) $reportData['faq_id'],
242                    $faqLanguage,
243                    TitleSlugifier::slug((string) ($reportData['faq_question'] ?? '')),
244                )));
245            }
246
247            if ($this->hasDataField(payload: $data, field: 'visits')) {
248                $text[$i][] = $reportData['faq_visits'];
249            }
250        }
251
252        $handle = fopen('php://temp', mode: 'r+');
253        foreach ($text as $row) {
254            fputcsv($handle, fields: $row, separator: ',', enclosure: '"', escape: '\\');
255        }
256
257        rewind($handle);
258
259        $content = (string) stream_get_contents($handle);
260
261        fclose($handle);
262
263        $response = new Response($content);
264        $response->headers->set('Content-Type', 'text/csv');
265        $response->headers->set('Content-Disposition', 'attachment; filename="report.csv"');
266
267        return $response;
268    }
269
270    /**
271     * Returns true when the report payload requests the given field.
272     */
273    private function hasDataField(\stdClass $payload, string $field): bool
274    {
275        return property_exists($payload, $field) && $payload->{$field} !== null;
276    }
277}