| 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 |
|