PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / PluginLogicSettingsUpdate.php

PluginLogicSettingsUpdate.php in 404 Solution 4.3.0, at includes/core/PluginLogicSettingsUpdate.php

442 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/../settings/SettingsFieldValidator.php';
8 require_once __DIR__ . '/../view/TableViewOptionsResolver.php';
9 require_once __DIR__ . '/../policies/SettingsAdminExcludedPagesPolicy.php';
10 require_once __DIR__ . '/../policies/SettingsBooleanModePolicy.php';
11 require_once __DIR__ . '/../policies/SettingsNotificationPolicy.php';
12 require_once __DIR__ . '/../policies/SettingsRedirectPolicy.php';
13 require_once __DIR__ . '/../policies/SettingsRegexPatternPolicy.php';
14 require_once __DIR__ . '/../policies/SettingsRetentionPolicy.php';
15 require_once __DIR__ . '/../policies/SettingsSuggestionPolicy.php';
16 require_once __DIR__ . '/../policies/SettingsWordPressPolicy.php';
17
18 /** Signals a miswired settings persistence dependency. */
19 class ABJ_404_Solution_SettingsPersistenceException extends \RuntimeException {}
20
21 /**
22 * Settings save workflow: nonce verification, encoded POST decoding,
23 * per-section option policy dispatch, persistence, and structured AJAX result
24 * assembly.
25 *
26 * Decomposed during the M201 audit (design-audit-2026-06-04.md).
27 */
28 class ABJ_404_Solution_PluginLogicSettingsUpdate {
29
30 /** @var ABJ_404_Solution_Functions */
31 private $f;
32
33 /** @var ABJ_404_Solution_Logging */
34 private $logger;
35
36 /** @var ABJ_404_Solution_ContentRepositoryInterface */
37 private $contentRepo;
38
39 /** @var ABJ_404_Solution_PluginLogic */
40 private $pluginLogic;
41
42 /** @var ABJ_404_Solution_TableViewOptionsResolver|null */
43 private $tableViewOptionsResolver;
44
45 /** @var ABJ_404_Solution_SettingsFieldValidator */
46 private $fieldValidator;
47
48 /** @var ABJ_404_Solution_SettingsRegexPatternPolicy */
49 private $regexPatternPolicy;
50
51 /** @var array<string, object> */
52 private $sectionPolicies = array();
53
54 /**
55 * @param ABJ_404_Solution_Functions $f
56 * @param ABJ_404_Solution_Logging $logger
57 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo
58 * @param ABJ_404_Solution_PluginLogic $pluginLogic
59 * @param ABJ_404_Solution_TableViewOptionsResolver|null $tableViewOptionsResolver
60 * @param ABJ_404_Solution_SettingsFieldValidator|null $fieldValidator
61 * @param ABJ_404_Solution_SettingsRegexPatternPolicy|null $regexPatternPolicy
62 */
63 function __construct(
64 $f,
65 $logger,
66 $contentRepo,
67 $pluginLogic,
68 $tableViewOptionsResolver = null,
69 $fieldValidator = null,
70 $regexPatternPolicy = null
71 ) {
72 $this->f = $f;
73 $this->logger = $logger;
74 $this->contentRepo = $contentRepo;
75 $this->pluginLogic = $pluginLogic;
76 $this->tableViewOptionsResolver = $tableViewOptionsResolver;
77 $this->fieldValidator = $fieldValidator !== null ? $fieldValidator : new ABJ_404_Solution_SettingsFieldValidator();
78 $this->regexPatternPolicy = $regexPatternPolicy !== null ? $regexPatternPolicy : new ABJ_404_Solution_SettingsRegexPatternPolicy($f);
79 }
80
81 /**
82 * Resolve the per-page table view options for an admin list-table.
83 *
84 * @param string $pageBeingViewed
85 * @return array<string, mixed>
86 */
87 function getTableOptions(string $pageBeingViewed): array {
88 return $this->tableViewOptionsResolver()->resolve($pageBeingViewed);
89 }
90
91 /**
92 * @param array<mixed, mixed> $postData
93 * @param bool $restoreNewlines
94 * @return array<string, mixed>
95 */
96 function sanitizePostData(array $postData, bool $restoreNewlines = false): array {
97 $newData = array();
98 foreach ($postData as $key => $value) {
99 $key = wp_kses_post($key);
100 if (is_array($value)) {
101 $newData[$key] = $this->sanitizePostData($value, $restoreNewlines);
102 } else {
103 if ($value === null) {
104 $newData[$key] = '';
105 } else {
106 $valueStr = is_string($value) ? $value : (is_scalar($value) ? (string)$value : '');
107 $newData[$key] = wp_kses_post($valueStr);
108 $newData[$key] = esc_sql($newData[$key]);
109 if ($restoreNewlines) {
110 $newData[$key] = str_replace('\n', "\n", $newData[$key]);
111 }
112 }
113 }
114 }
115 return $newData;
116 }
117
118 /** Remove non a-zA-Z0-9 or _ characters.
119 * @param string $str
120 * @return string
121 */
122 function sanitizeForSQL($str) {
123 if ($str == null || $str == '') {
124 return '';
125 }
126 $result = preg_replace('/[^\w_]/', '', $str);
127 return is_string($result) ? $result : $str;
128 }
129
130 /**
131 * @return array<string, mixed>
132 */
133 function updateOptionsFromPOST() {
134 $decodedRequest = $this->decodeSettingsPost($_POST);
135 if (!$decodedRequest['success']) {
136 return $decodedRequest;
137 }
138 if (!isset($decodedRequest['postData']) || !is_array($decodedRequest['postData'])) {
139 $this->logger->errorMessage('Settings request decode returned success without postData array');
140 return array('success' => false, 'status' => 400, 'message' => 'Missing form data');
141 }
142
143 $_POST = $decodedRequest['postData'];
144 if (array_key_exists('deleteDebugFile', $_POST) && $_POST['deleteDebugFile'] == true) {
145 $returnData = $this->baseSettingsUpdateData();
146 $returnData['error'] = '';
147 $sub = '';
148 $returnData['message'] = $this->pluginLogic->adminActions()->handlePluginAction('updateOptions', $sub);
149 return $this->successResult($returnData);
150 }
151
152 $options = $this->loadCurrentOptions();
153 $message = $this->applySettingsSections($options, $_POST);
154 $this->persistOptions($options);
155
156 return $this->successResult($this->settingsSaveData($message));
157 }
158
159 /**
160 * Decode a settings POST array into the actual form payload.
161 *
162 * @param array<string, mixed> $post
163 * @return array<string, mixed>
164 */
165 private function decodeSettingsPost(array $post): array {
166 if (!isset($post['encodedData'])) {
167 $this->logger->errorMessage('Missing encodedData in POST');
168 return $this->settingsRequestFailure(400, 'Missing form data');
169 }
170
171 $encodedData = $post['encodedData'];
172 $encodedData = is_scalar($encodedData) ? (string)$encodedData : '';
173
174 try {
175 $postData = call_user_func(array(abj_service('query_string_helper'), 'decodeComplicatedData'), $encodedData);
176 } catch (\Throwable $e) {
177 $this->logger->errorMessage('Invalid JSON encodedData in POST: ' . $e->getMessage());
178 return $this->settingsRequestFailure(400, 'Missing form data');
179 }
180
181 if (!is_array($postData)) {
182 $this->logger->errorMessage('Invalid JSON encodedData in POST');
183 return $this->settingsRequestFailure(400, 'Missing form data');
184 }
185
186 $nonce = isset($postData['nonce']) && is_scalar($postData['nonce']) ? (string)$postData['nonce'] : '';
187 if (!wp_verify_nonce($nonce, 'abj404UpdateOptions') || !is_admin()) {
188 return $this->settingsRequestFailure(403, 'Invalid security token');
189 }
190
191 return array(
192 'success' => true,
193 'postData' => $postData,
194 );
195 }
196
197 /**
198 * @param int $status
199 * @param string $message
200 * @return array<string, mixed>
201 */
202 private function settingsRequestFailure(int $status, string $message): array {
203 return array(
204 'success' => false,
205 'status' => $status,
206 'message' => $message,
207 );
208 }
209
210 /** @return array<string, mixed> */
211 private function loadCurrentOptions(): array {
212 $repository = abj_service('options_repository');
213 if (!is_callable(array($repository, 'getOptions'))) {
214 throw new ABJ_404_Solution_SettingsPersistenceException('Options repository cannot load settings options.');
215 }
216 $options = call_user_func(array($repository, 'getOptions'));
217 return is_array($options) ? $options : array();
218 }
219
220 /**
221 * Sanitize and persist options, then refresh the permalink cache.
222 *
223 * @param array<string, mixed> $options
224 * @return array<string, mixed>
225 */
226 private function persistOptions(array $options): array {
227 $excludedPages = isset($options['excludePages[]']) ? $options['excludePages[]'] : '';
228
229 $newOptions = $this->sanitizePostData($options, true);
230
231 $excludedPages = ($excludedPages == null || !is_scalar($excludedPages)) ? '' : trim((string)$excludedPages);
232 $excludedPages = preg_replace('/[^\[\",\]a-zA-Z\d\|\\\\ ]/', '', $excludedPages);
233 $newOptions['excludePages[]'] = is_string($excludedPages) ? $excludedPages : '';
234
235 $repository = abj_service('options_repository');
236 if (!is_callable(array($repository, 'updateOptions'))) {
237 throw new ABJ_404_Solution_SettingsPersistenceException('Options repository cannot persist settings options.');
238 }
239 call_user_func(array($repository, 'updateOptions'), $newOptions);
240
241 $permalinkCache = abj_service('permalink_cache');
242 if (!is_callable(array($permalinkCache, 'updatePermalinkCache'))) {
243 throw new ABJ_404_Solution_SettingsPersistenceException('Permalink cache cannot refresh after settings save.');
244 }
245 call_user_func(array($permalinkCache, 'updatePermalinkCache'), 2);
246
247 return $newOptions;
248 }
249
250 /**
251 * @param string $errorMessage Concatenated translated validation messages.
252 * @return array<string, mixed>
253 */
254 private function settingsSaveData(string $errorMessage): array {
255 $returnData = $this->baseSettingsUpdateData();
256 $returnData['error'] = $errorMessage;
257 if ($errorMessage === "") {
258 $returnData['message'] = __('Options Saved Successfully!', '404-solution');
259 } else {
260 $returnData['message'] = __('Some options were not saved successfully.', '404-solution') .
261 ' ' . $errorMessage;
262 }
263 return $returnData;
264 }
265
266 /** @return array<string, mixed> */
267 private function baseSettingsUpdateData(): array {
268 return array(
269 'newURL' => admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options',
270 );
271 }
272
273 /**
274 * @param array<string, mixed> $data
275 * @return array<string, mixed>
276 */
277 private function successResult(array $data): array {
278 return array(
279 'success' => true,
280 'status' => 200,
281 'data' => $data,
282 );
283 }
284
285 /**
286 * @param array<string, mixed> $options
287 * @param array<string, mixed> $postData
288 */
289 private function applySettingsSections(array &$options, array $postData): string {
290 $message = "";
291 $message .= $this->updateRedirectSettings($options, $postData);
292 $message .= $this->updateWordPressSettings($options, $postData);
293 $message .= $this->updateNotificationSettings($options, $postData);
294 $message .= $this->updateDeletionSettings($options, $postData);
295 $message .= $this->updateSuggestionSettings($options, $postData);
296 $message .= $this->updateBooleanToggles($options, $postData);
297 $message .= $this->updateSuggestionHTMLOptions($options, $postData);
298 $message .= $this->updateRegexPatternSettings($options, $postData);
299 $message .= $this->updateAdminUsers($options, $postData);
300 $message .= $this->updateExcludedPages($options, $postData);
301 return $message;
302 }
303
304 /**
305 * @param array<string, mixed> $options
306 * @param array<string, mixed> $postData
307 * @return string
308 */
309 private function updateRedirectSettings(array &$options, array $postData): string {
310 return $this->redirectPolicy()->apply($options, $postData);
311 }
312
313 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
314 public function updateWordPressSettings(array &$options, array $postData): string {
315 return $this->wordPressPolicy()->apply($options, $postData);
316 }
317
318 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
319 private function updateNotificationSettings(&$options, $postData) {
320 return $this->notificationPolicy()->apply($options, $postData);
321 }
322
323 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
324 public function updateDeletionSettings(array &$options, array $postData): string {
325 return $this->retentionPolicy()->apply($options, $postData);
326 }
327
328 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
329 public function updateSuggestionSettings(array &$options, array $postData): string {
330 return $this->suggestionPolicy()->applyScoringOptions($options, $postData);
331 }
332
333 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
334 public function updateBooleanToggles(array &$options, array $postData): string {
335 return $this->booleanModePolicy()->apply($options, $postData);
336 }
337
338 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
339 private function updateSuggestionHTMLOptions(array &$options, array $postData): string {
340 return $this->suggestionPolicy()->applyTemplateOptions($options, $postData);
341 }
342
343 /**
344 * @param array<string, mixed> $options
345 * @return bool True when any option was changed.
346 */
347 function normalizeSuggestionTemplateOptions(array &$options): bool {
348 return $this->suggestionPolicy()->normalizeTemplateOptions($options);
349 }
350
351 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
352 private function updateRegexPatternSettings(array &$options, array $postData): string {
353 return $this->regexPatternPolicy->apply($options, $postData);
354 }
355
356 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
357 private function updateAdminUsers(array &$options, array $postData): string {
358 return $this->adminExcludedPagesPolicy()->applyAdminUsers($options, $postData);
359 }
360
361 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
362 private function updateExcludedPages(array &$options, array $postData): string {
363 return $this->adminExcludedPagesPolicy()->applyExcludedPages($options, $postData);
364 }
365
366 /** @return ABJ_404_Solution_TableViewOptionsResolver */
367 private function tableViewOptionsResolver(): ABJ_404_Solution_TableViewOptionsResolver {
368 if ($this->tableViewOptionsResolver === null) {
369 $self = $this;
370 $this->tableViewOptionsResolver = new ABJ_404_Solution_TableViewOptionsResolver(
371 $this->f,
372 function (array $tableOptions) use ($self) {
373 return $self->sanitizePostData($tableOptions);
374 }
375 );
376 }
377 return $this->tableViewOptionsResolver;
378 }
379
380 /** @return ABJ_404_Solution_SettingsRedirectPolicy */
381 private function redirectPolicy(): ABJ_404_Solution_SettingsRedirectPolicy {
382 return $this->policy('redirect', ABJ_404_Solution_SettingsRedirectPolicy::class, function () {
383 return new ABJ_404_Solution_SettingsRedirectPolicy();
384 });
385 }
386
387 /** @return ABJ_404_Solution_SettingsWordPressPolicy */
388 private function wordPressPolicy(): ABJ_404_Solution_SettingsWordPressPolicy {
389 return $this->policy('wordpress', ABJ_404_Solution_SettingsWordPressPolicy::class, function () {
390 return new ABJ_404_Solution_SettingsWordPressPolicy();
391 });
392 }
393
394 /** @return ABJ_404_Solution_SettingsNotificationPolicy */
395 private function notificationPolicy(): ABJ_404_Solution_SettingsNotificationPolicy {
396 return $this->policy('notification', ABJ_404_Solution_SettingsNotificationPolicy::class, function () {
397 return new ABJ_404_Solution_SettingsNotificationPolicy($this->logger);
398 });
399 }
400
401 /** @return ABJ_404_Solution_SettingsRetentionPolicy */
402 private function retentionPolicy(): ABJ_404_Solution_SettingsRetentionPolicy {
403 return $this->policy('retention', ABJ_404_Solution_SettingsRetentionPolicy::class, function () {
404 return new ABJ_404_Solution_SettingsRetentionPolicy($this->fieldValidator);
405 });
406 }
407
408 /** @return ABJ_404_Solution_SettingsSuggestionPolicy */
409 private function suggestionPolicy(): ABJ_404_Solution_SettingsSuggestionPolicy {
410 return $this->policy('suggestion', ABJ_404_Solution_SettingsSuggestionPolicy::class, function () {
411 return new ABJ_404_Solution_SettingsSuggestionPolicy($this->logger, $this->contentRepo);
412 });
413 }
414
415 /** @return ABJ_404_Solution_SettingsBooleanModePolicy */
416 private function booleanModePolicy(): ABJ_404_Solution_SettingsBooleanModePolicy {
417 return $this->policy('booleanMode', ABJ_404_Solution_SettingsBooleanModePolicy::class, function () {
418 return new ABJ_404_Solution_SettingsBooleanModePolicy($this->contentRepo);
419 });
420 }
421
422 /** @return ABJ_404_Solution_SettingsAdminExcludedPagesPolicy */
423 private function adminExcludedPagesPolicy(): ABJ_404_Solution_SettingsAdminExcludedPagesPolicy {
424 return $this->policy('adminExcludedPages', ABJ_404_Solution_SettingsAdminExcludedPagesPolicy::class, function () {
425 return new ABJ_404_Solution_SettingsAdminExcludedPagesPolicy($this->f, $this->logger, $this->contentRepo);
426 });
427 }
428
429 /**
430 * @template T of object
431 * @param class-string<T> $className
432 * @param callable(): T $factory
433 * @return T
434 */
435 private function policy(string $name, string $className, callable $factory) {
436 if (!isset($this->sectionPolicies[$name]) || !$this->sectionPolicies[$name] instanceof $className) {
437 $this->sectionPolicies[$name] = $factory();
438 }
439 return $this->sectionPolicies[$name];
440 }
441 }
442