Lines 83.07% 216 / 260
Methods 64.28% 18 / 28
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 exportFile 68.42% 13 / 19 0.00% 0 / 1 4.50
 exportReport 92.10% 35 / 38 0.00% 0 / 1 16.13
 buildReportResponse 63.15% 36 / 57 0.00% 0 / 1 40.00
 hasDataField 100.00% 1 / 1 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] initializeFromContainer 78.57% 11 / 14 0.00% 0 / 1 4.16
 [phpMyFAQ\Controller\AbstractController] render 100.00% 5 / 5 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] renderView 0.00% 0 / 3 0.00% 0 / 1 2
 [phpMyFAQ\Controller\AbstractController] json 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getJsonObject 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\Controller\AbstractController] getTwigWrapper 100.00% 10 / 10 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] hasValidToken 85.71% 6 / 7 0.00% 0 / 1 5.07
 [phpMyFAQ\Controller\AbstractController] isSecured 100.00% 10 / 10 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] isPublicAuthenticationPath 100.00% 23 / 23 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] userIsAuthenticated 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userIsSuperAdmin 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userHasGroupPermission 100.00% 8 / 8 100.00% 1 / 1 6
 [phpMyFAQ\Controller\AbstractController] userHasUserPermission 100.00% 7 / 7 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] userHasPermission 100.00% 5 / 5 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] userHasAnyPermission 100.00% 10 / 10 100.00% 1 / 1 4
 [phpMyFAQ\Controller\AbstractController] verifySessionCsrfToken 70.00% 7 / 10 0.00% 0 / 1 4.43
 [phpMyFAQ\Controller\AbstractController] captchaCodeIsValid 85.71% 6 / 7 0.00% 0 / 1 2.01
 [phpMyFAQ\Controller\AbstractController] isApiEnabled 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addExtension 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addFilter 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getRateLimiter 100.00% 4 / 4 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] createFallbackContainer 71.42% 5 / 7 0.00% 0 / 1 2.09
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}

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
104    protected function initializeFromContainer(): void
105    {
106        $configuration = $this->container->get(id: 'phpmyfaq.configuration');
107        if (!$configuration instanceof Configuration) {
108            throw new LogicException('Configuration service not found in container.');
109        }
110
111        $this->configuration = $configuration;
112
113        $currentUser = $this->container->get(id: 'phpmyfaq.user.current_user');
114        if (!$currentUser instanceof CurrentUser) {
115            throw new LogicException('CurrentUser service not found in container.');
116        }
117
118        $this->currentUser = $currentUser;
119
120        $session = $this->container->get(id: 'session');
121        if (!$session instanceof FlashBagAwareSessionInterface) {
122            throw new LogicException('Session service not found in container.');
123        }
124
125        $this->session = $session;
126
127        TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet());
128        $this->isSecured();
129    }
137    public function render(string $file, array $context = [], ?Response $response = null): Response
138    {
139        $response ??= new Response();
140        $twigWrapper = $this->getTwigWrapper();
141        $templateWrapper = $twigWrapper->loadTemplate($file);
142
143        $response->setContent($templateWrapper->render($context));
144
145        return $response;
146    }
154    public function renderView(string $pathToTwigFile, array $templateVars = []): string
155    {
156        $twigWrapper = $this->getTwigWrapper();
157        $templateWrapper = $twigWrapper->loadTemplate($pathToTwigFile);
158
159        return $templateWrapper->render($templateVars);
160    }
167    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
168    {
169        return new JsonResponse($data, $status, $headers);
170    }
182    protected function getJsonObject(Request $request): \stdClass
183    {
184        /* @mago-expect analysis:mixed-assignment - json_decode() is mixed by nature; validated to stdClass below */
185        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
186
187        if (!$data instanceof \stdClass) {
188            throw new JsonException('The request body must be a JSON object.');
189        }
190
191        return $data;
192    }
197    public function getTwigWrapper(): TwigWrapper
198    {
199        $twigWrapper = new TwigWrapper(
200            (string) PMF_ROOT_DIR . '/assets/templates',
201            false,
202            $this->configuration->getTemplateSet(),
203        );
204
205        foreach ($this->twigExtensions as $twigExtension) {
206            $twigWrapper->addExtension($twigExtension);
207        }
208
209        foreach ($this->twigFilters as $twigFilter) {
210            $twigWrapper->addFilter($twigFilter);
211        }
212
213        return $twigWrapper;
214    }
219    protected function hasValidToken(): void
220    {
221        $configuredToken = $this->configuration->get(item: 'api.apiClientToken');
222        if (!is_string($configuredToken) || $configuredToken === '') {
223            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
224        }
225
226        $request = Request::createFromGlobals();
227        $requestToken = $request->headers->get(key: 'x-pmf-token');
228        if (!is_string($requestToken) || !hash_equals($configuredToken, $requestToken)) {
229            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
230        }
231    }
236    protected function isSecured(): void
237    {
238        if ($this->currentUser->isLoggedIn()) {
239            return;
240        }
241
242        if (!$this->configuration->get(item: 'security.enableLoginOnly')) {
243            return;
244        }
245
246        $request = Request::createFromGlobals();
247        $pathInfo = rtrim($request->getPathInfo(), characters: '/');
248        $pathInfo = $pathInfo === '' ? '/' : $pathInfo;
249
250        if ($this->isPublicAuthenticationPath($pathInfo)) {
251            return;
252        }
253
254        throw new UnauthorizedHttpException(challenge: 'You are not allowed to view this content.');
255    }
257    private function isPublicAuthenticationPath(string $pathInfo): bool
258    {
259        $publicAuthenticationPaths = [
260            '/login',
261            '/authenticate',
262            '/forgot-password',
263            '/token',
264            '/check',
265            '/contact.html',
266            '/imprint.html',
267            '/privacy.html',
268            '/terms.html',
269            '/accessibility.html',
270            '/auth/azure/authorize',
271            '/auth/azure/callback',
272            '/auth/azure/callback.php',
273            '/auth/keycloak/authorize',
274            '/auth/keycloak/callback',
275            '/auth/keycloak/logout',
276            '/services/azure/callback',
277            '/services/azure/callback.php',
278            '/api/webauthn/prepare-login',
279            '/api/webauthn/login',
280        ];
281
282        return in_array($pathInfo, $publicAuthenticationPaths, strict: true);
283    }
288    public function userIsAuthenticated(): void
289    {
290        if (!$this->currentUser->isLoggedIn()) {
291            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
292        }
293    }
298    protected function userIsSuperAdmin(): void
299    {
300        if (!$this->currentUser->isSuperAdmin()) {
301            throw new UnauthorizedHttpException(challenge: 'User is not super admin.');
302        }
303    }
308    protected function userHasGroupPermission(): void
309    {
310        if (!$this->currentUser->isLoggedIn()) {
311            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
312        }
313
314        $currentUser = $this->currentUser;
315        if (
316            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
317            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
318            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
319            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::GROUP_EDIT->value)
320        ) {
321            throw new ForbiddenException(message: 'User has no group permission.');
322        }
323    }
328    protected function userHasUserPermission(): void
329    {
330        if (!$this->currentUser->isLoggedIn()) {
331            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
332        }
333
334        $currentUser = $this->currentUser;
335        if (
336            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
337            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
338            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
339        ) {
340            throw new ForbiddenException(message: 'User has no user permission.');
341        }
342    }
347    protected function userHasPermission(PermissionType $permissionType): void
348    {
349        if (!$this->currentUser->isLoggedIn()) {
350            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
351        }
352
353        $currentUser = $this->currentUser;
354        if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
355            throw new ForbiddenException(message: sprintf('User has no "%s" permission.', $permissionType->name));
356        }
357    }
364    protected function userHasAnyPermission(PermissionType ...$permissionTypes): void
365    {
366        if (!$this->currentUser->isLoggedIn()) {
367            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
368        }
369
370        $currentUser = $this->currentUser;
371        foreach ($permissionTypes as $permissionType) {
372            if ($currentUser->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
373                return;
374            }
375        }
376
377        throw new ForbiddenException(message: sprintf('User has none of the required permissions: %s.', implode(', ', array_map(
378            static fn(PermissionType $type): string => $type->name,
379            $permissionTypes,
380        ))));
381    }
389    protected function verifySessionCsrfToken(string $page, #[\SensitiveParameter] string $requestToken): bool
390    {
391        if ($requestToken === '') {
392            return false;
393        }
394
395        $sessionKey = sprintf('pmf-csrf-token.%s', $page);
396        $storedToken = $this->session->get($sessionKey);
397
398        if (!$storedToken instanceof Token) {
399            return false;
400        }
401
402        if (time() > $storedToken->getExpiry()) {
403            $this->session->remove($sessionKey);
404            return false;
405        }
406
407        return hash_equals($storedToken->getSessionToken(), $requestToken);
408    }
414    protected function captchaCodeIsValid(Request $request): bool
415    {
416        $captcha = Captcha::getInstance($this->configuration);
417        $captcha->setUserIsLoggedIn($this->currentUser->isLoggedIn());
418
419        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
420
421        $code = Filter::filterVar($data->captcha ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
422        if ($this->configuration->get(item: 'security.enableGoogleReCaptchaV2')) {
423            $code = Filter::filterVar($data->{'g-recaptcha-response'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
424        }
425
426        return $captcha->checkCaptchaCode((string) $code);
427    }
429    public function isApiEnabled(): bool
430    {
431        return (bool) $this->configuration->get(item: 'api.enableAccess');
432    }
434    public function addExtension(ExtensionInterface $extension): void
435    {
436        $this->twigExtensions[] = $extension;
437    }
439    public function addFilter(TwigFilter $twigFilter): void
440    {
441        $this->twigFilters[] = $twigFilter;
442    }
444    protected function getRateLimiter(): ?RateLimiter
445    {
446        if (!$this->container->has('phpmyfaq.http.rate-limiter')) {
447            return null;
448        }
449
450        $rateLimiter = $this->container->get('phpmyfaq.http.rate-limiter');
451
452        return $rateLimiter instanceof RateLimiter ? $rateLimiter : null;
453    }
455    private function createFallbackContainer(): ContainerBuilder
456    {
457        $containerBuilder = new ContainerBuilder();
458        $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
459        try {
460            $phpFileLoader->load(resource: '../../services.php');
461        } catch (\Exception $exception) {
462            error_log($exception->getMessage());
463        }
464
465        // Register Forms services
466        FormsServiceProvider::register($containerBuilder);
467
468        return $containerBuilder;
469    }