Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
ConfigController
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
11 / 11
68
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 edit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
19
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFileWritable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 validation
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
11
 parseOptions
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
17
 filterShownSettings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 activePage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 validateEvent
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers\Admin;
6
7use Engelsystem\Application;
8use Engelsystem\Config\Config;
9use Engelsystem\Controllers\BaseController;
10use Engelsystem\Controllers\HasUserNotifications;
11use Engelsystem\Helpers\Authenticator;
12use Engelsystem\Helpers\Carbon;
13use Engelsystem\Helpers\CarbonDay;
14use Engelsystem\Http\Exceptions\HttpForbidden;
15use Engelsystem\Http\Exceptions\HttpNotFound;
16use Engelsystem\Http\Redirector;
17use Engelsystem\Http\Request;
18use Engelsystem\Http\Response;
19use Engelsystem\Http\UrlGeneratorInterface;
20use Engelsystem\Models\EventConfig;
21use Illuminate\Support\Str;
22use InvalidArgumentException;
23use Psr\Log\LoggerInterface;
24
25class ConfigController extends BaseController
26{
27    use HasUserNotifications;
28
29    protected string $localConfig;
30
31    protected string $passwordPlaceholder = '**********';
32
33    protected array $permissions = [
34        'config.edit',
35    ];
36
37    protected array $options = [];
38
39    public function __construct(
40        protected Response $response,
41        protected Config $config,
42        protected Redirector $redirect,
43        protected UrlGeneratorInterface $url,
44        protected LoggerInterface $log,
45        protected Authenticator $auth,
46        Application $app,
47        bool $withAll = false, # Used to get all config options, for example for docs
48    ) {
49        $this->localConfig = $app->get('path.config') . '/config.local.php';
50        $this->options = $this->config->get('config_options', []);
51        $this->parseOptions($withAll);
52    }
53
54    public function index(): Response
55    {
56        return $this->redirect->to('/admin/config/' . array_key_first($this->options));
57    }
58
59    public function edit(Request $request): Response
60    {
61        $page = $this->activePage($request);
62
63        return $this->response->withView(
64            'admin/config/index',
65            [
66                'page' => $page,
67                'title' => $this->options[$page]['title'],
68                'config' => $this->options[$page]['config'],
69                'options' => $this->options,
70            ]
71        );
72    }
73
74    public function save(Request $request): Response
75    {
76        $page = $this->activePage($request);
77        $data = $this->validation($page, $request);
78        $settings = $this->filterShownSettings($this->options[$page]['config']);
79        $localConfigWritable = $this->isFileWritable($this->localConfig);
80
81        $changes = [];
82        foreach ($settings as $key => $options) {
83            # Request field names with . are parsed with _
84            $fieldKey = Str::replace('.', '_', $key);
85            $default = $options['default'] ?? null;
86            $value = array_key_exists($fieldKey, $data) ? $data[$fieldKey] : $default;
87
88            $value = match ($options['type']) {
89                'datetime-local' => $value ? Carbon::createFromDatetime($value) : $value,
90                'date' => $value ? CarbonDay::createFromDay($value) : $value,
91                'boolean' => !empty($value),
92                'number' => (float) $value,
93                'password' => $value === $this->passwordPlaceholder ? $this->config->get($key) : $value,
94                'select_multi' => is_null($value) ? [] : $value,
95                default => $value,
96            };
97
98            if ($this->config->get($key) == $value) {
99                continue;
100            }
101
102            $writeBack = $options['write_back'] ?? false;
103            $setValue = $value !== $default && !is_null($value) && $value !== '';
104            if ($setValue && !$writeBack) {
105                (new EventConfig())
106                    ->findOrNew($key)
107                    ->setAttribute('name', $key)
108                    ->setAttribute('value', $value)
109                    ->save();
110            } else {
111                (new EventConfig())
112                    ->whereName($key)
113                    ->delete();
114            }
115
116            $this->config->set($key, $value);
117
118            if ($writeBack && $localConfigWritable) {
119                $oldConfig = [];
120                if (file_exists($this->localConfig)) {
121                    $oldConfig = include $this->localConfig;
122                }
123                $config = new Config($oldConfig);
124                if ($setValue) {
125                    $config->set($key, $value);
126                } else {
127                    $config->remove($key);
128                }
129                $configContent =
130                    '<?php // !!! Do not edit this file, it will be overwritten on config change !!!' . PHP_EOL .
131                    'return ' . var_export($config->get(null), true) . ';' . PHP_EOL;
132                file_put_contents($this->localConfig, $configContent);
133
134                // Clear config file from PHPs OPcache to load it on next request
135                if (function_exists('opcache_invalidate')) {
136                    opcache_invalidate($this->localConfig, true);
137                }
138            }
139
140            $value = $options['type'] !== 'password' ? $value : $this->passwordPlaceholder;
141            $changes[] = sprintf('%s = %s', $key, json_encode($value));
142        }
143
144        if ($changes) {
145            $this->log->info(
146                'Updated {page} configuration: {changes}',
147                [
148                    'page' => $page,
149                    'changes' => implode(', ', $changes),
150                ]
151            );
152
153            $this->addNotification('config.edit.success');
154        }
155
156        return $this->redirect->back();
157    }
158
159    public function getOptions(): array
160    {
161        return $this->options;
162    }
163
164    protected function isFileWritable(string $file): bool
165    {
166        return
167            (file_exists($file) && is_writable($file))
168            || (!file_exists($file) && is_writable(dirname($file)));
169    }
170
171    protected function validation(string $page, Request $request): array
172    {
173        $rules = [];
174        $config = $this->options[$page];
175        $settings = $this->filterShownSettings($config['config']);
176
177        // Generate validation rules
178        foreach ($settings as $key => $setting) {
179            $validation = [];
180            $validation[] = empty($setting['required']) ? 'optional' : 'required';
181            # Request field names wih . are parsed with _
182            $key = Str::replace('.', '_', $key);
183
184            if (
185                !empty($setting['validation'])
186                // Ignore unchanged passwords
187                && !($setting['type'] === 'password' && $request->postData($key) === $this->passwordPlaceholder)
188            ) {
189                $validation = array_merge($validation, $setting['validation']);
190            }
191
192            match ($setting['type']) {
193                'string', 'text' => null, // Anything is valid here when optional
194                'datetime-local' => $validation[] = 'date_time',
195                'date' => $validation[] = 'date',
196                'boolean' => $validation[] = 'checked',
197                'number' => $validation[] = 'number',
198                'url' => $validation[] = 'url',
199                'email' => $validation[] = 'email',
200                'select' => $validation[] = 'in:' . implode(',', array_keys($setting['data'])),
201                'select_multi' => $validation[] = 'array_val|in_many:' . implode(',', array_keys($setting['data'])),
202                'password' =>
203                    $request->postData($key) !== $this->passwordPlaceholder
204                    && (!empty($setting['required']) || $request->postData($key))
205                    ? $validation[] = 'length:' . $this->config->get('password_min_length')
206                    : null,
207                default => throw new InvalidArgumentException(
208                    'Type ' . $setting['type'] . ' of ' . $key . ' is not defined'
209                ),
210            };
211
212            $rules[$key] = implode('|', $validation);
213        }
214
215        if (!empty($config['validation']) && is_callable($config['validation'])) {
216            return $config['validation']($request, $rules);
217        }
218
219        return $this->validate($request, $rules);
220    }
221
222    protected function parseOptions(bool $withAll = true): void
223    {
224        $fromEnv = array_filter($this->config->get('env_config'), fn($a) => !is_null($a));
225        $localConfigWritable = $this->isFileWritable($this->localConfig);
226
227        foreach ($this->options as $key => $value) {
228            // Add page URLs
229            $this->options[$key]['url'] = $this->url->to('/admin/config/' . $key);
230
231            // Configure page translation names
232            if (empty($this->options[$key]['title'])) {
233                $this->options[$key]['title'] = 'config.' . $key;
234            }
235
236            // Define internal validation action
237            $internalValidation = 'validate' . Str::ucfirst($key);
238            if (method_exists($this, $internalValidation)) {
239                // Used until proper dynamic config loading is implemented
240                $this->options[$key]['validation'] = [$this, $internalValidation];
241            }
242
243            // Iterate over settings
244            foreach ($this->options[$key]['config'] as $name => $config) {
245                // Ignore hidden options
246                if (!empty($config['hidden']) && !$withAll) {
247                    unset($this->options[$key]['config'][$name]);
248                    continue;
249                }
250
251                // Set name for translation
252                if (empty($this->options[$key]['config'][$name]['name'])) {
253                    $this->options[$key]['config'][$name]['name'] = 'config.' . $name;
254                }
255
256                // Configure required icon
257                if (!empty($this->options[$key]['config'][$name]['required'])) {
258                    $this->options[$key]['config'][$name]['required_icon'] = true;
259                }
260
261                // Set ENV name
262                if (empty($config['env'])) {
263                    $config['env'] = Str::upper($name);
264                    $this->options[$key]['config'][$name]['env'] = $config['env'];
265                }
266
267                // Configure select values
268                if ($config['type'] == 'select' || $config['type'] == 'select_multi') {
269                    $data = [];
270                    foreach ($config['data'] ?? [] as $dataKey => $dataValue) {
271                        if (is_int($dataKey)) {
272                            $dataKey = $dataValue;
273                            $dataValue = 'config.' . $name . '.select.' . $dataKey;
274                        }
275                        $data[$dataKey] = $dataValue;
276                    }
277                    $this->options[$key]['config'][$name]['data'] = $data;
278                }
279
280                // Set if overwritten from ENV
281                if (isset($fromEnv[$config['env']])) {
282                    $this->options[$key]['config'][$name]['in_env'] = true;
283                }
284
285                // Set if local config can be written to
286                if (!$localConfigWritable && ($config['write_back'] ?? false)) {
287                    $this->options[$key]['config'][$name]['writable'] = false;
288                }
289            }
290        }
291    }
292
293    protected function filterShownSettings(array $settings): array
294    {
295        // Ignore values from env
296        $settings = array_filter($settings, fn($a) => empty($a['in_env']));
297        // Skip if permissions don't match
298        return array_filter($settings, fn($a) => empty($a['permission']) || $this->auth->can($a['permission']));
299    }
300
301    protected function activePage(Request $request): string
302    {
303        $page = $request->getAttribute('page');
304
305        if (empty($this->options[$page])) {
306            throw new HttpNotFound();
307        }
308
309        $permissions = $this->options[$page]['permission'] ?? null;
310        if (!empty($permissions) && !$this->auth->can($permissions)) {
311            throw new HttpForbidden();
312        }
313
314        return $page;
315    }
316
317    /**
318     * Validation for Event page
319     */
320    protected function validateEvent(Request $request, array $rules): array
321    {
322        // Run general validation
323        $data = $this->validate($request, $rules);
324        $addedRules = [];
325
326        // Ensure event dates are after each other
327        $dates = ['buildup_start', 'event_start', 'event_end', 'teardown_end'];
328        foreach ($dates as $i => $dateField) {
329            if (!$i || !$data[$dateField]) {
330                continue;
331            }
332
333            foreach (array_slice($dates, 0, $i) as $previousDateField) {
334                if (!$data[$previousDateField]) {
335                    continue;
336                }
337
338                $addedRules[$dateField][] = ['after', $data[$previousDateField], 'true'];
339            }
340        }
341
342        if (!empty($addedRules)) {
343            $this->validate($request, $addedRules);
344        }
345
346        return $data;
347    }
348}