PluginProbe
CryptX / 4.2.0
CryptX v4.2.0
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / Admin / SettingsSchema.php

SettingsSchema.php in CryptX 4.2.0, at classes/Admin/SettingsSchema.php

639 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX\Admin;
4
5 use CryptX\CryptX;
6
7 /**
8 * The single description of every CryptX setting.
9 *
10 * Label, explanation, type, allowed values, default, which tab it belongs to
11 * and whether it is an everyday or a rare setting -- all of it lives here and
12 * nowhere else. The REST controller validates against this array, and the
13 * React screen renders itself from it. A new option therefore cannot end up
14 * with a control but no validation, or with validation but no explanation.
15 *
16 * @package CryptX
17 * @since 4.1.0
18 */
19 final class SettingsSchema
20 {
21 public const TAB_PROTECTION = 'protection';
22 public const TAB_APPEARANCE = 'appearance';
23 public const TAB_EXCEPTIONS = 'exceptions';
24 public const TAB_ADVANCED = 'advanced';
25
26 /**
27 * Options that exist in the stored array but are never offered for editing.
28 *
29 * 'version' and the four secret-related keys are written by the plugin
30 * itself. Three of those are key material -- the retired image secret opens
31 * every token made before the last rotation -- and none of them may ever be
32 * settable from a form. Listing them here changes nothing on its own,
33 * because sanitize() already drops anything without a field definition;
34 * they are named so that the list matches its own description, which is how
35 * the shortcode equivalent came to be missing them for a release;
36 * 'echo' is a leftover that no code path reads any more;
37 * 'use_secure_encryption' is derived from 'encryption_mode' on save, see
38 * deriveImpliedValues() -- two switches for one decision only ever
39 * contradict each other.
40 */
41 private const INTERNAL_KEYS = [
42 'version',
43 'encryption_password',
44 'image_token_secret',
45 'image_token_secret_previous',
46 'image_token_secret_previous_until',
47 'secrets_rotated_at',
48 'review_prompt_due',
49 'echo',
50 'use_secure_encryption',
51 ];
52
53 /**
54 * The tabs, in the order they appear.
55 *
56 * @return array<int, array{id: string, label: string, description: string}>
57 */
58 public static function tabs(): array
59 {
60 return [
61 [
62 'id' => self::TAB_PROTECTION,
63 'label' => __('Protection', 'cryptx'),
64 'description' => __('Where CryptX looks for email addresses, and how it hides them.', 'cryptx'),
65 ],
66 [
67 'id' => self::TAB_APPEARANCE,
68 'label' => __('Appearance', 'cryptx'),
69 'description' => __('What your visitors see in place of the address.', 'cryptx'),
70 ],
71 [
72 'id' => self::TAB_EXCEPTIONS,
73 'label' => __('Exceptions', 'cryptx'),
74 'description' => __('Content that CryptX should leave alone.', 'cryptx'),
75 ],
76 [
77 'id' => self::TAB_ADVANCED,
78 'label' => __('Advanced', 'cryptx'),
79 'description' => __('Rarely needed. The defaults are right for almost every site.', 'cryptx'),
80 ],
81 ];
82 }
83
84 /**
85 * Every editable option.
86 *
87 * @return array<string, array<string, mixed>>
88 */
89 public static function fields(): array
90 {
91 // Filled once per request: the list depends on what is in the fonts
92 // directory, and fields() is called several times per request.
93 //
94 // An empty result is deliberately not cached. Were the directory
95 // briefly unreadable, caching [] would reinstate for the rest of the
96 // request exactly the state this cache was introduced to remove: every
97 // font choice falling back to the default.
98 static $fonts = null;
99
100 if ($fonts === null || $fonts === []) {
101 $fonts = self::availableFonts();
102 }
103
104 return [
105 // -------------------------------------------------- Protection --
106 'the_content' => [
107 'type' => 'boolean',
108 'default' => true,
109 'tab' => self::TAB_PROTECTION,
110 'section' => __('Where CryptX applies', 'cryptx'),
111 'label' => __('Posts and pages', 'cryptx'),
112 'help' => __('The main body of your posts and pages. This is the setting almost everyone wants. It can be switched off for single posts under Exceptions.', 'cryptx'),
113 ],
114 'the_excerpt' => [
115 'type' => 'boolean',
116 'default' => true,
117 'tab' => self::TAB_PROTECTION,
118 'section' => __('Where CryptX applies', 'cryptx'),
119 'label' => __('Excerpts', 'cryptx'),
120 'help' => __('The short summaries many themes show on archive and search pages.', 'cryptx'),
121 ],
122 'comment_text' => [
123 'type' => 'boolean',
124 'default' => true,
125 'tab' => self::TAB_PROTECTION,
126 'section' => __('Where CryptX applies', 'cryptx'),
127 'label' => __('Comments', 'cryptx'),
128 'help' => __('Addresses that visitors leave in the text of a comment. Worth keeping on: you have no control over what people type there.', 'cryptx'),
129 ],
130 'widget_text' => [
131 'type' => 'boolean',
132 'default' => true,
133 'tab' => self::TAB_PROTECTION,
134 'section' => __('Where CryptX applies', 'cryptx'),
135 'label' => __('Widgets', 'cryptx'),
136 'help' => __('Text and HTML widgets, including block widgets. A contact address in a sidebar is one of the most common places a spam bot finds one.', 'cryptx'),
137 ],
138 'the_meta_key' => [
139 'type' => 'boolean',
140 'default' => true,
141 'tab' => self::TAB_PROTECTION,
142 'section' => __('Where CryptX applies', 'cryptx'),
143 'label' => __('Custom fields', 'cryptx'),
144 'help' => __('Only takes effect where a theme prints custom fields with the_meta(). Most modern themes do not, so this rarely changes anything.', 'cryptx'),
145 ],
146 'autolink' => [
147 'type' => 'boolean',
148 'default' => true,
149 'tab' => self::TAB_PROTECTION,
150 'section' => __('Recognition', 'cryptx'),
151 'label' => __('Turn plain addresses into links', 'cryptx'),
152 'help' => __('With this on, an address written as plain text becomes a working contact link and is protected. With it off, CryptX only protects addresses that were already linked -- a plain one stays readable for spam bots. Widgets are the exception, as long as they are switched on above: there a plain address is linked and protected either way. That is long-standing behaviour and errs towards protection, so it has been left as it is rather than changed under people who rely on it.', 'cryptx'),
153 ],
154 'java' => [
155 'type' => 'choice',
156 'default' => 1,
157 'tab' => self::TAB_PROTECTION,
158 'section' => __('Method', 'cryptx'),
159 'label' => __('How the link is hidden', 'cryptx'),
160 'help' => __('JavaScript protects considerably better, because the address is not in the page at all until someone clicks. Unicode keeps the link working without JavaScript, but a spam bot that decodes HTML entities will still read it.', 'cryptx'),
161 'choices' => [
162 ['value' => 1, 'label' => __('JavaScript (recommended)', 'cryptx')],
163 ['value' => 0, 'label' => __('Unicode, works without JavaScript', 'cryptx')],
164 ],
165 ],
166 'encryption_mode' => [
167 'type' => 'choice',
168 'default' => 'secure',
169 'tab' => self::TAB_PROTECTION,
170 'section' => __('Method', 'cryptx'),
171 'label' => __('Encryption', 'cryptx'),
172 'help' => __('Secure uses AES-256-GCM and costs a little computing time per page. Compatible uses the original CryptX method, which is far cheaper and still hides the address from anything that just scans the page text. Links created earlier keep working either way.', 'cryptx'),
173 'depends' => ['java' => 1],
174 'choices' => [
175 ['value' => 'secure', 'label' => __('Secure (AES-256-GCM)', 'cryptx')],
176 ['value' => 'legacy', 'label' => __('Compatible, faster', 'cryptx')],
177 ],
178 ],
179
180 // -------------------------------------------------- Appearance --
181 'opt_linktext' => [
182 'type' => 'choice',
183 'default' => 0,
184 'tab' => self::TAB_APPEARANCE,
185 'section' => __('What visitors see', 'cryptx'),
186 'label' => __('Instead of the address, show', 'cryptx'),
187 'help' => __('The address in the link target is always protected. This only decides what is written on the link itself. Note that both picture options trade accessibility for protection: a visitor using a screen reader or a text browser cannot read the address at all, only follow the link. The picture drawn by CryptX is made on your own server, and since 4.2.0 it is fetched under a web address that gives nothing away -- before that the email address stood in it, and so in your access log.', 'cryptx'),
188 'choices' => [
189 ['value' => 0, 'label' => __('The address with @ and . replaced', 'cryptx')],
190 ['value' => 1, 'label' => __('A text of your choice', 'cryptx')],
191 ['value' => 2, 'label' => __('An image from a web address', 'cryptx')],
192 ['value' => 3, 'label' => __('An image from the media library', 'cryptx')],
193 ['value' => 4, 'label' => __('The address as HTML entities', 'cryptx')],
194 ['value' => 5, 'label' => __('The address drawn into a picture', 'cryptx')],
195 ],
196 ],
197 'at' => [
198 'type' => 'string',
199 'default' => ' [at] ',
200 'tab' => self::TAB_APPEARANCE,
201 'section' => __('What visitors see', 'cryptx'),
202 'label' => __('Replacement for @', 'cryptx'),
203 'help' => __('Leading and trailing spaces are kept, so " [at] " reads as a word rather than running into the address.', 'cryptx'),
204 'depends' => ['opt_linktext' => 0],
205 ],
206 'dot' => [
207 'type' => 'string',
208 'default' => ' [dot] ',
209 'tab' => self::TAB_APPEARANCE,
210 'section' => __('What visitors see', 'cryptx'),
211 'label' => __('Replacement for .', 'cryptx'),
212 'help' => __('Applies to every dot in the address, including the one in the domain.', 'cryptx'),
213 'depends' => ['opt_linktext' => 0],
214 ],
215 'alt_linktext' => [
216 'type' => 'string',
217 'default' => '',
218 'tab' => self::TAB_APPEARANCE,
219 'section' => __('What visitors see', 'cryptx'),
220 'label' => __('Link text', 'cryptx'),
221 'help' => __('Shown in place of every address on the site. Useful when there is one contact address; confusing when there are several, because they all end up looking the same. Left empty, the link has no text at all -- the preview above shows what that looks like.', 'cryptx'),
222 'depends' => ['opt_linktext' => 1],
223 ],
224 'alt_linkimage' => [
225 'type' => 'url',
226 'default' => '',
227 'tab' => self::TAB_APPEARANCE,
228 'section' => __('What visitors see', 'cryptx'),
229 'label' => __('Image address', 'cryptx'),
230 'help' => __('Full web address of the image to show instead of the text.', 'cryptx'),
231 'depends' => ['opt_linktext' => 2],
232 ],
233 'http_linkimage_title' => [
234 'type' => 'string',
235 'default' => '',
236 'tab' => self::TAB_APPEARANCE,
237 'section' => __('What visitors see', 'cryptx'),
238 'label' => __('Image description', 'cryptx'),
239 'help' => __('Used as the alt text. Screen readers read this out, so describe what the image is for -- "Write us an email" rather than "envelope".', 'cryptx'),
240 'depends' => ['opt_linktext' => 2],
241 ],
242 'alt_uploadedimage' => [
243 'type' => 'media',
244 'default' => 0,
245 'tab' => self::TAB_APPEARANCE,
246 'section' => __('What visitors see', 'cryptx'),
247 'label' => __('Image', 'cryptx'),
248 'help' => __('Picked from your media library. The plugin stores the attachment, not the address, so the image survives a move to a different domain.', 'cryptx'),
249 'depends' => ['opt_linktext' => 3],
250 ],
251 'alt_linkimage_title' => [
252 'type' => 'string',
253 'default' => '',
254 'tab' => self::TAB_APPEARANCE,
255 'section' => __('What visitors see', 'cryptx'),
256 'label' => __('Image description', 'cryptx'),
257 'help' => __('Used as the alt text for screen readers.', 'cryptx'),
258 'depends' => ['opt_linktext' => 3],
259 ],
260
261 // These three used to sit under Advanced, one tab away from the
262 // choice they serve, with nothing to say where they had gone.
263 'c2i_font' => [
264 'type' => 'choice',
265 'tab' => self::TAB_APPEARANCE,
266 'section' => __('Address as a picture', 'cryptx'),
267 'label' => __('Font', 'cryptx'),
268 'help' => __('Used when the address is drawn into a picture. The fonts shipped with CryptX are freely licensed.', 'cryptx'),
269 // Like every other dependent field: shown when the option it
270 // serves is actually in use, and out of the way otherwise.
271 'depends' => ['opt_linktext' => 5],
272 // The choices belong here rather than only in forClient():
273 // sanitize() validates against fields(), so an empty list here
274 // meant every save silently reset the stored font to ''.
275 'choices' => $fonts,
276 'default' => $fonts[0]['value'] ?? '',
277 ],
278 'c2i_fontSize' => [
279 'type' => 'integer',
280 'default' => 10,
281 'min' => 6,
282 'max' => 96,
283 'tab' => self::TAB_APPEARANCE,
284 'section' => __('Address as a picture', 'cryptx'),
285 'label' => __('Font size in pixels', 'cryptx'),
286 'help' => __('The picture is generated at this size. Larger means a sharper image on high resolution screens, but also a larger file.', 'cryptx'),
287 'depends' => ['opt_linktext' => 5],
288 ],
289 'c2i_fontRGB' => [
290 'type' => 'color',
291 'default' => '#000000',
292 'tab' => self::TAB_APPEARANCE,
293 'section' => __('Address as a picture', 'cryptx'),
294 'label' => __('Text colour', 'cryptx'),
295 'help' => __('The background stays transparent, so the picture sits on whatever colour your theme uses.', 'cryptx'),
296 'depends' => ['opt_linktext' => 5],
297 ],
298
299 // -------------------------------------------------- Exceptions --
300 'excludedIDs' => [
301 'type' => 'idlist',
302 'default' => '',
303 'tab' => self::TAB_EXCEPTIONS,
304 'section' => __('Individual posts', 'cryptx'),
305 'label' => __('Leave these posts and pages alone', 'cryptx'),
306 'help' => __('Post and page IDs, separated by commas. Addresses in them stay exactly as written. The shortcode still works there, so you can protect single addresses by hand.', 'cryptx'),
307 ],
308 'metaBox' => [
309 'type' => 'boolean',
310 'default' => true,
311 'tab' => self::TAB_EXCEPTIONS,
312 'section' => __('Individual posts', 'cryptx'),
313 'label' => __('Offer a switch in the post editor', 'cryptx'),
314 'help' => __('Adds a "Disable CryptX for this post/page" box to the editor, which writes into the list above. Without it the list can still be edited here.', 'cryptx'),
315 ],
316 'exemptAddresses' => [
317 'type' => 'addresslist',
318 'default' => '',
319 'tab' => self::TAB_EXCEPTIONS,
320 'section' => __('Individual addresses', 'cryptx'),
321 'label' => __('Addresses to leave alone', 'cryptx'),
322 'help' => __('Separated by commas. CryptX leaves these exactly as written -- no masking, no link, no encryption -- for an address a helpdesk has to read out of the page, or one in a code example. Write "@example.com" to cover a whole domain. Two limits on purpose: the shortcode and the block say "protect this one, here" and are never overruled by this list; and in comments only whole addresses count, never the domain form, so an exempt domain cannot be used to collect what visitors leave behind. (In comments WordPress makes a link out of a bare address by itself, before CryptX sees it.)', 'cryptx'),
323 ],
324 'whiteList' => [
325 'type' => 'string',
326 'default' => 'jpeg,jpg,png,gif',
327 'tab' => self::TAB_EXCEPTIONS,
328 'section' => __('False positives', 'cryptx'),
329 'label' => __('Endings that are not addresses', 'cryptx'),
330 'help' => __('Anything ending in one of these is left alone. This is what keeps file names such as logo@2x.png from being treated as an email address. To exempt a real address, use the field above instead.', 'cryptx'),
331 ],
332 'disable_rss' => [
333 'type' => 'boolean',
334 'default' => true,
335 'tab' => self::TAB_EXCEPTIONS,
336 'section' => __('Feeds', 'cryptx'),
337 'label' => __('Leave RSS feeds unprotected', 'cryptx'),
338 'help' => __('Feed readers do not run JavaScript, so a protected link would be dead in a feed. The trade-off is real: with this on, addresses are readable in your feed. Switch it off only if you accept that your feed links stop working for some readers.', 'cryptx'),
339 ],
340
341 // ---------------------------------------------------- Advanced --
342 'iterations' => [
343 'type' => 'choice',
344 'default' => 10000,
345 'tab' => self::TAB_ADVANCED,
346 'section' => __('Encryption', 'cryptx'),
347 'label' => __('Key strengthening', 'cryptx'),
348 'help' => __('How much work goes into deriving the key. The cost is paid once per page, not per address. Higher makes life harder for anyone trying to unpick the addresses in bulk; lower renders pages faster. Changing it is safe: since 4.2.0 every link records what it was made with, so the ones already published keep working. Before that, changing this quietly broke all of them.', 'cryptx'),
349 'depends' => ['encryption_mode' => 'secure'],
350 'choices' => [
351 ['value' => 100000, 'label' => __('Thorough (100,000)', 'cryptx')],
352 ['value' => 10000, 'label' => __('Balanced (10,000)', 'cryptx')],
353 ['value' => 1000, 'label' => __('Fast (1,000)', 'cryptx')],
354 ],
355 ],
356 'link_mode' => [
357 'type' => 'choice',
358 'default' => 'data',
359 'tab' => self::TAB_ADVANCED,
360 'section' => __('Encryption', 'cryptx'),
361 'label' => __('Link format', 'cryptx'),
362 'help' => __('Data attributes work on sites with a Content-Security-Policy, where a javascript: link is blocked and every CryptX link would silently stop working. The old format additionally breaks wherever content passes through WordPress\'s own wp_kses_post() -- the Elementor text widget and many page builders do this, and it strips "javascript:" out of the link while leaving the rest behind. Only switch to the old format if something in your setup depends on it.', 'cryptx'),
363 'choices' => [
364 ['value' => 'data', 'label' => __('Data attributes (recommended)', 'cryptx')],
365 ['value' => 'js', 'label' => __('javascript: link, as before 4.0.12', 'cryptx')],
366 ],
367 ],
368 'load_java' => [
369 'type' => 'choice',
370 'default' => 1,
371 'tab' => self::TAB_ADVANCED,
372 'section' => __('Script', 'cryptx'),
373 'label' => __('Load the script', 'cryptx'),
374 'help' => __('In the footer the script is only loaded on pages that actually contain a protected address. In the header that decision cannot be made yet, so it is loaded everywhere -- choose the header only if a theme needs it early.', 'cryptx'),
375 'choices' => [
376 ['value' => 1, 'label' => __('In the footer (recommended)', 'cryptx')],
377 ['value' => 0, 'label' => __('In the header', 'cryptx')],
378 ],
379 ],
380 'css_id' => [
381 'type' => 'htmlid',
382 'default' => '',
383 'tab' => self::TAB_ADVANCED,
384 'section' => __('Styling', 'cryptx'),
385 'label' => __('CSS id for the link', 'cryptx'),
386 'help' => __('Added to every generated link. Remember that an id is meant to appear once per page -- with several addresses on one page, a class is the better choice.', 'cryptx'),
387 ],
388 'css_class' => [
389 'type' => 'htmlid',
390 'default' => '',
391 'tab' => self::TAB_ADVANCED,
392 'section' => __('Styling', 'cryptx'),
393 'label' => __('CSS class for the link', 'cryptx'),
394 'help' => __('Added to every generated link, alongside the class CryptX needs for itself.', 'cryptx'),
395 ],
396 ];
397 }
398
399 /**
400 * The schema as the settings screen consumes it, with runtime values filled in.
401 *
402 * @param array<int, string> $without Field keys to leave out -- the network
403 * defaults screen uses this for the two settings that mean something
404 * different on every site.
405 *
406 * @return array<string, mixed>
407 */
408 public static function forClient(array $without = []): array
409 {
410 $fields = array_diff_key(self::fields(), array_flip($without));
411 $out = [];
412 foreach ($fields as $key => $definition) {
413 $definition['key'] = $key;
414 $out[] = $definition;
415 }
416
417 // A tab whose every field was left out is not shown at all: an empty
418 // tab reads as a broken screen rather than as a deliberate omission.
419 // No tab loses everything today -- Exceptions keeps four fields even
420 // without the two per-site ones -- so this is for the next omission,
421 // not for the current one.
422 $used = array_unique(array_column($out, 'tab'));
423
424 $tabs = array_values(array_filter(
425 self::tabs(),
426 static fn(array $tab): bool => in_array($tab['id'], $used, true)
427 ));
428
429 return [
430 'tabs' => $tabs,
431 'fields' => $out,
432 ];
433 }
434
435 /**
436 * The TTF files shipped in the fonts directory.
437 *
438 * @return array<int, array{value: string, label: string}>
439 */
440 private static function availableFonts(): array
441 {
442 $files = CryptX::get_instance()->getFilesInDirectory(CRYPTX_DIR_PATH . 'fonts', ['ttf']);
443 $fonts = [];
444
445 foreach ($files as $file) {
446 $fonts[] = [
447 'value' => $file,
448 'label' => str_replace('_', ' ', preg_replace('/\.ttf$/i', '', $file)),
449 ];
450 }
451
452 return $fonts;
453 }
454
455 /**
456 * Default values for every editable option.
457 *
458 * @return array<string, mixed>
459 */
460 public static function defaults(): array
461 {
462 $defaults = [];
463 foreach (self::fields() as $key => $definition) {
464 $defaults[$key] = $definition['default'];
465 }
466
467 return $defaults;
468 }
469
470 /**
471 * Validates and cleans incoming values.
472 *
473 * Anything that is not a known key is dropped, not merely cleaned: the
474 * screen has no business writing options it does not know about, and the
475 * internal ones must never arrive from a request at all.
476 *
477 * @param array<string, mixed> $input Raw values.
478 *
479 * @return array<string, mixed> Values fit to be stored.
480 */
481 public static function sanitize(array $input): array
482 {
483 $fields = self::fields();
484 $clean = [];
485
486 foreach ($input as $key => $value) {
487 if (!isset($fields[$key]) || in_array($key, self::INTERNAL_KEYS, true)) {
488 continue;
489 }
490
491 $definition = $fields[$key];
492 $clean[$key] = self::sanitizeValue($value, $definition);
493 }
494
495 return self::deriveImpliedValues($clean);
496 }
497
498 /**
499 * Cleans one value according to its type.
500 *
501 * @param mixed $value The incoming value.
502 * @param array<string, mixed> $definition The field definition.
503 *
504 * @return mixed The cleaned value.
505 */
506 private static function sanitizeValue($value, array $definition)
507 {
508 if (is_array($value)) {
509 return $definition['default'];
510 }
511
512 switch ($definition['type']) {
513 case 'boolean':
514 return (bool) $value ? 1 : 0;
515
516 case 'integer':
517 $number = (int) $value;
518 $min = $definition['min'] ?? PHP_INT_MIN;
519 $max = $definition['max'] ?? PHP_INT_MAX;
520
521 return max($min, min($max, $number));
522
523 case 'choice':
524 foreach ($definition['choices'] as $choice) {
525 // Loose comparison on purpose: a choice value of 1 arrives
526 // from JSON as an integer but from a form as the string "1".
527 if ($choice['value'] == $value) {
528 return $choice['value'];
529 }
530 }
531
532 return $definition['default'];
533
534 case 'color':
535 $color = sanitize_hex_color(is_string($value) ? $value : '');
536
537 return $color ?? $definition['default'];
538
539 case 'url':
540 return esc_url_raw((string) $value);
541
542 case 'media':
543 return absint($value);
544
545 case 'htmlid':
546 return sanitize_html_class((string) $value);
547
548 case 'addresslist':
549 // Two forms, and both have to survive: a whole address, and
550 // "@example.com" for a domain. sanitize_email() would eat the
551 // second one -- it needs a local part -- so the two are
552 // cleaned apart.
553 $entries = [];
554
555 foreach (explode(',', (string) $value) as $entry) {
556 $entry = strtolower(trim($entry));
557
558 if ($entry === '') {
559 continue;
560 }
561
562 if (str_starts_with($entry, '@')) {
563 $domain = substr($entry, 1);
564
565 // A domain, judged by asking whether it makes a valid
566 // address rather than by a pattern of our own.
567 if (is_email('user@' . $domain)) {
568 $entries[] = '@' . $domain;
569 }
570
571 continue;
572 }
573
574 // Rejected, not repaired. sanitize_email() strips whatever
575 // it dislikes and hands back the rest, so a typo becomes a
576 // different, perfectly valid address: "info@exam ple.com"
577 // turns into "info@example.com", and a domain typed with an
578 // umlaut loses the letter rather than the entry.
579 // Silently exempting an address nobody
580 // typed is the one thing this field must not do, and an
581 // entry that vanishes is noticed -- one that changed is
582 // not. The domain form above is already this strict.
583 $address = sanitize_email($entry);
584
585 if ($address !== '' && $address === $entry) {
586 $entries[] = $address;
587 }
588 }
589
590 return implode(',', array_unique($entries));
591
592 case 'idlist':
593 $ids = array_filter(array_map('absint', explode(',', (string) $value)));
594 sort($ids);
595
596 return implode(',', array_unique($ids));
597
598 case 'string':
599 default:
600 // wp_kses_post rather than sanitize_text_field: the replacements
601 // for @ and . are allowed to carry simple markup, and their
602 // leading and trailing spaces have to survive.
603 return wp_kses_post((string) $value);
604 }
605 }
606
607 /**
608 * Fills in the options that follow from another one.
609 *
610 * 'use_secure_encryption' exists in storage but has no control: two
611 * switches for one decision only ever end up contradicting each other, and
612 * the stored defaults for these two did exactly that before 4.1.0.
613 *
614 * @param array<string, mixed> $values Cleaned values.
615 *
616 * @return array<string, mixed> Values including the derived ones.
617 */
618 private static function deriveImpliedValues(array $values): array
619 {
620 if (isset($values['encryption_mode'])) {
621 $values['use_secure_encryption'] = $values['encryption_mode'] === 'secure' ? 1 : 0;
622 }
623
624 return $values;
625 }
626
627 /**
628 * Whether a key is one the settings screen must never write.
629 *
630 * @param string $key The option key.
631 *
632 * @return bool
633 */
634 public static function isInternal(string $key): bool
635 {
636 return in_array($key, self::INTERNAL_KEYS, true);
637 }
638 }
639