Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.70% covered (success)
80.70%
46 / 57
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
MetaService
80.70% covered (success)
80.70%
46 / 57
71.43% covered (warning)
71.43%
5 / 7
15.41
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
 getPublicMetadata
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 buildEnabledFeatures
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 buildPublicLogoUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildThemeColors
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
4.31
 extractThemeVariables
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 toBool
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * Public metadata service for the REST API bootstrap endpoint.
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-04-11
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Api;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Helper\LanguageHelper;
24
25final readonly class MetaService
26{
27    public function __construct(
28        private Configuration $configuration,
29        private OAuthDiscoveryService $oAuthDiscoveryService,
30    ) {
31    }
32
33    /**
34     * @return array{
35     *     version: string,
36     *     title: string,
37     *     language: string,
38     *     availableLanguages: array<array-key, string>,
39     *     enabledFeatures: array<string, bool>,
40     *     publicLogoUrl: string,
41     *     themeColors: array<string, array<string, string>>,
42     *     oauthDiscovery: array<string, bool|string|string[]>
43     * }
44     */
45    public function getPublicMetadata(): array
46    {
47        return [
48            'version' => $this->configuration->getVersion(),
49            'title' => $this->configuration->getTitle(),
50            'language' => $this->configuration->getLanguage()->getLanguage(),
51            'availableLanguages' => LanguageHelper::getAvailableLanguages(),
52            'enabledFeatures' => $this->buildEnabledFeatures(),
53            'publicLogoUrl' => $this->buildPublicLogoUrl(),
54            'themeColors' => $this->buildThemeColors(),
55            'oauthDiscovery' => $this->oAuthDiscoveryService->getMetaDiscovery(),
56        ];
57    }
58
59    /**
60     * @return array<string, bool>
61     */
62    private function buildEnabledFeatures(): array
63    {
64        return [
65            'api' => true,
66            'oauth2' => $this->toBool($this->configuration->get('oauth2.enable')),
67            'captcha' => $this->toBool($this->configuration->get('spam.enableCaptchaCode')),
68            'ldap' => $this->configuration->isLdapActive(),
69            'elasticsearch' => $this->toBool($this->configuration->get('search.enableElasticsearch')),
70            'opensearch' => $this->toBool($this->configuration->get('search.enableOpenSearch')),
71            'sso' => $this->toBool($this->configuration->get('security.ssoSupport')),
72            'signInWithMicrosoft' => $this->configuration->isSignInWithMicrosoftActive(),
73        ];
74    }
75
76    private function buildPublicLogoUrl(): string
77    {
78        return rtrim($this->configuration->getDefaultUrl(), characters: '/') . '/assets/images/logo-transparent.svg';
79    }
80
81    /**
82     * @return array<string, array<string, string>>
83     */
84    private function buildThemeColors(): array
85    {
86        $themeCssPath = (string) PMF_ROOT_DIR . '/assets/templates/default/theme.css';
87        if (!is_readable($themeCssPath)) {
88            return [
89                'light' => [],
90                'dark' => [],
91                'highContrast' => [],
92            ];
93        }
94
95        $themeCss = file_get_contents($themeCssPath);
96        if ($themeCss === false) {
97            return [
98                'light' => [],
99                'dark' => [],
100                'highContrast' => [],
101            ];
102        }
103
104        return [
105            'light' => $this->extractThemeVariables($themeCss, ":root,\n[data-bs-theme='light']"),
106            'dark' => $this->extractThemeVariables($themeCss, "[data-bs-theme='dark']"),
107            'highContrast' => $this->extractThemeVariables($themeCss, "[data-bs-theme='high-contrast']"),
108        ];
109    }
110
111    /**
112     * @return array<string, string>
113     */
114    private function extractThemeVariables(string $themeCss, string $selector): array
115    {
116        $pattern = sprintf('/%s\s*\{(?P<body>.*?)^\}/ms', preg_quote($selector, delimiter: '/'));
117        $matches = [];
118        if (preg_match($pattern, $themeCss, $matches) !== 1) {
119            return [];
120        }
121
122        $variableMatches = [];
123        preg_match_all(
124            '/(?P<name>--[A-Za-z0-9\-]+)\s*:\s*(?P<value>[^;]+);/',
125            (string) $matches['body'],
126            $variableMatches,
127            PREG_SET_ORDER,
128        );
129
130        $variables = [];
131        foreach ($variableMatches as $variableMatch) {
132            $variables[$variableMatch['name']] = trim($variableMatch['value']);
133        }
134
135        return $variables;
136    }
137
138    private function toBool(mixed $value): bool
139    {
140        return $value === true || $value === 1 || $value === '1' || $value === 'true';
141    }
142}