Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
130 / 130
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
ConfigController
100.00% covered (success)
100.00%
130 / 130
100.00% covered (success)
100.00%
9 / 9
52
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 save
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
19
 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
 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
5
 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\Helpers\Authenticator;
10use Engelsystem\Helpers\Carbon;
11use Engelsystem\Helpers\CarbonDay;
12use Engelsystem\Http\Exceptions\HttpForbidden;
13use Engelsystem\Http\Exceptions\HttpNotFound;
14use Engelsystem\Http\Redirector;
15use Engelsystem\Http\Request;
16use Engelsystem\Http\Response;
17use Engelsystem\Models\EventConfig;
18use Illuminate\Support\Str;
19use InvalidArgumentException;
20use Psr\Log\LoggerInterface;
21
22class ConfigController extends BaseConfigController
23{
24    protected string $localConfig;
25
26    protected string $passwordPlaceholder = '**********';
27
28    public function __construct(
29        Application $app,
30        protected Config $config,
31        protected Authenticator $auth,
32        protected Redirector $redirect,
33        protected Response $response,
34        protected LoggerInterface $log,
35    ) {
36        parent::__construct();
37        $this->localConfig = $app->get('path.config') . '/config.local.php';
38        $localConfigWriteable = $this->isFileWritable($this->localConfig);
39        $this->parseOptions($localConfigWriteable);
40    }
41
42    public function index(): Response
43    {
44        return $this->redirect->to('/admin/config/' . array_key_first($this->options));
45    }
46
47    public function edit(Request $request): Response
48    {
49        $page = $this->activePage($request);
50
51        if (empty($this->options[$page]['url_added'])) {
52            return $this->redirect->to($this->options[$page]['url']);
53        }
54
55        return $this->response->withView(
56            'admin/config/index',
57            [
58                ...$this->getPageData($page),
59            ]
60        );
61    }
62
63    public function save(Request $request): Response
64    {
65        $page = $this->activePage($request);
66        $data = $this->validation($page, $request);
67        $settings = $this->filterShownSettings($this->options[$page]['config']);
68        $localConfigWritable = $this->isFileWritable($this->localConfig);
69
70        $changes = [];
71        foreach ($settings as $key => $options) {
72            # Request field names with . are parsed with _
73            $fieldKey = Str::replace('.', '_', $key);
74            $default = $options['default'] ?? null;
75            $value = array_key_exists($fieldKey, $data) ? $data[$fieldKey] : $default;
76
77            $value = match ($options['type']) {
78                'datetime-local' => $value ? Carbon::createFromDatetime($value) : $value,
79                'date' => $value ? CarbonDay::createFromDay($value) : $value,
80                'boolean' => !empty($value),
81                'number' => (float) $value,
82                'password' => $value === $this->passwordPlaceholder ? $this->config->get($key) : $value,
83                'select_multi' => is_null($value) ? [] : $value,
84                default => $value,
85            };
86
87            if ($this->config->get($key) == $value) {
88                continue;
89            }
90
91            $writeBack = $options['write_back'] ?? false;
92            $setValue = $value !== $default && !is_null($value) && $value !== '';
93            if ($setValue && !$writeBack) {
94                (new EventConfig())
95                    ->findOrNew($key)
96                    ->setAttribute('name', $key)
97                    ->setAttribute('value', $value)
98                    ->save();
99            } else {
100                (new EventConfig())
101                    ->whereName($key)
102                    ->delete();
103            }
104
105            $this->config->set($key, $value);
106
107            if ($writeBack && $localConfigWritable) {
108                $oldConfig = [];
109                if (file_exists($this->localConfig)) {
110                    $oldConfig = include $this->localConfig;
111                }
112                $config = new Config($oldConfig);
113                if ($setValue) {
114                    $config->set($key, $value);
115                } else {
116                    $config->remove($key);
117                }
118                $configContent =
119                    '<?php // !!! Do not edit this file, it will be overwritten on config change !!!' . PHP_EOL .
120                    'return ' . var_export($config->get(null), true) . ';' . PHP_EOL;
121                file_put_contents($this->localConfig, $configContent);
122
123                // Clear config file from PHPs OPcache to load it on next request
124                if (function_exists('opcache_invalidate')) {
125                    opcache_invalidate($this->localConfig, true);
126                }
127            }
128
129            $value = $options['type'] !== 'password' ? $value : $this->passwordPlaceholder;
130            $changes[] = sprintf('%s = %s', $key, json_encode($value));
131        }
132
133        if ($changes) {
134            $this->log->info(
135                'Updated {page} configuration: {changes}',
136                [
137                    'page' => $page,
138                    'changes' => implode(', ', $changes),
139                ]
140            );
141
142            $this->addNotification('config.edit.success');
143        }
144
145        return $this->redirect->back();
146    }
147
148    protected function isFileWritable(string $file): bool
149    {
150        return
151            (file_exists($file) && is_writable($file))
152            || (!file_exists($file) && is_writable(dirname($file)));
153    }
154
155    protected function validation(string $page, Request $request): array
156    {
157        $rules = [];
158        $config = $this->options[$page];
159        $settings = $this->filterShownSettings($config['config']);
160
161        // Generate validation rules
162        foreach ($settings as $key => $setting) {
163            $validation = [];
164            $validation[] = empty($setting['required']) ? 'optional' : 'required';
165            # Request field names wih . are parsed with _
166            $key = Str::replace('.', '_', $key);
167
168            if (
169                !empty($setting['validation'])
170                // Ignore unchanged passwords
171                && !($setting['type'] === 'password' && $request->postData($key) === $this->passwordPlaceholder)
172            ) {
173                $validation = array_merge($validation, $setting['validation']);
174            }
175
176            match ($setting['type']) {
177                'string', 'text' => null, // Anything is valid here when optional
178                'datetime-local' => $validation[] = 'date_time',
179                'date' => $validation[] = 'date',
180                'boolean' => $validation[] = 'checked',
181                'number' => $validation[] = 'number',
182                'url' => $validation[] = 'url',
183                'email' => $validation[] = 'email',
184                'select' => $validation[] = 'in:' . implode(',', array_keys($setting['data'])),
185                'select_multi' => $validation[] = 'array_val|in_many:' . implode(',', array_keys($setting['data'])),
186                'password' =>
187                $request->postData($key) !== $this->passwordPlaceholder
188                && (!empty($setting['required']) || $request->postData($key))
189                    ? $validation[] = 'length:' . $this->config->get('password_min_length')
190                    : null,
191                default => throw new InvalidArgumentException(
192                    'Type ' . $setting['type'] . ' of ' . $key . ' is not defined'
193                ),
194            };
195
196            $rules[$key] = implode('|', $validation);
197        }
198
199        if (!empty($config['validation']) && is_callable($config['validation'])) {
200            return $config['validation']($request, $rules);
201        }
202
203        return $this->validate($request, $rules);
204    }
205
206    protected function filterShownSettings(array $settings): array
207    {
208        // Ignore values from env
209        $settings = array_filter($settings, fn($a) => empty($a['in_env']));
210        // Skip if permissions don't match
211        return array_filter($settings, fn($a) => empty($a['permission']) || $this->auth->can($a['permission']));
212    }
213
214    protected function activePage(Request $request): string
215    {
216        $page = $request->getAttribute('page');
217
218        if (!$page || empty($this->options[$page])) {
219            throw new HttpNotFound();
220        }
221
222        $permissions = $this->options[$page]['permission'] ?? null;
223        if (!empty($permissions) && !$this->auth->can($permissions)) {
224            throw new HttpForbidden();
225        }
226
227        return $page;
228    }
229
230    /**
231     * Validation for Event page
232     */
233    protected function validateEvent(Request $request, array $rules): array
234    {
235        // Run general validation
236        $data = $this->validate($request, $rules);
237        $addedRules = [];
238
239        // Ensure event dates are after each other
240        $dates = ['buildup_start', 'event_start', 'event_end', 'teardown_end'];
241        foreach ($dates as $i => $dateField) {
242            if (!$i || !$data[$dateField]) {
243                continue;
244            }
245
246            foreach (array_slice($dates, 0, $i) as $previousDateField) {
247                if (!$data[$previousDateField]) {
248                    continue;
249                }
250
251                $addedRules[$dateField][] = ['after', $data[$previousDateField], 'true'];
252            }
253        }
254
255        if (!empty($addedRules)) {
256            $this->validate($request, $addedRules);
257        }
258
259        return $data;
260    }
261}