| @@ -25,15 +25,31 @@ | ||
| 25 | 25 | |
| 26 | 26 | /** |
| 27 | 27 | * Options that exist in the stored array but are never offered for editing. |
| 28 | 28 | * |
| 29 | - * 'version' and 'encryption_password' are written by the plugin itself; | |
| 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; | |
| 30 | 36 | * 'echo' is a leftover that no code path reads any more; |
| 31 | 37 | * 'use_secure_encryption' is derived from 'encryption_mode' on save, see |
| 32 | 38 | * deriveImpliedValues() -- two switches for one decision only ever |
| 33 | 39 | * contradict each other. |
| 34 | 40 | */ |
| 35 | - private const INTERNAL_KEYS = ['version', 'encryption_password', 'echo', 'use_secure_encryption']; | |
| 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 | + ]; | |
| 36 | 52 | |
| 37 | 53 | /** |
| 38 | 54 | * The tabs, in the order they appear. |
| 39 | 55 | * |
| @@ -132,9 +148,9 @@ | ||
| 132 | 148 | 'default' => true, |
| 133 | 149 | 'tab' => self::TAB_PROTECTION, |
| 134 | 150 | 'section' => __('Recognition', 'cryptx'), |
| 135 | 151 | 'label' => __('Turn plain addresses into links', 'cryptx'), |
| 136 | - '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.', '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'), | |
| 137 | 153 | ], |
| 138 | 154 | 'java' => [ |
| 139 | 155 | 'type' => 'choice', |
| 140 | 156 | 'default' => 1, |
| @@ -167,9 +183,9 @@ | ||
| 167 | 183 | 'default' => 0, |
| 168 | 184 | 'tab' => self::TAB_APPEARANCE, |
| 169 | 185 | 'section' => __('What visitors see', 'cryptx'), |
| 170 | 186 | 'label' => __('Instead of the address, show', 'cryptx'), |
| 171 | - 'help' => __('The address in the link target is always protected. This only decides what is written on the link itself.', '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'), | |
| 172 | 188 | 'choices' => [ |
| 173 | 189 | ['value' => 0, 'label' => __('The address with @ and . replaced', 'cryptx')], |
| 174 | 190 | ['value' => 1, 'label' => __('A text of your choice', 'cryptx')], |
| 175 | 191 | ['value' => 2, 'label' => __('An image from a web address', 'cryptx')], |
| @@ -296,8 +312,16 @@ | ||
| 296 | 312 | 'section' => __('Individual posts', 'cryptx'), |
| 297 | 313 | 'label' => __('Offer a switch in the post editor', 'cryptx'), |
| 298 | 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'), |
| 299 | 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 | + ], | |
| 300 | 324 | 'whiteList' => [ |
| 301 | 325 | 'type' => 'string', |
| 302 | 326 | 'default' => 'jpeg,jpg,png,gif', |
| 303 | 327 | 'tab' => self::TAB_EXCEPTIONS, |
| @@ -302,9 +326,9 @@ | ||
| 302 | 326 | 'default' => 'jpeg,jpg,png,gif', |
| 303 | 327 | 'tab' => self::TAB_EXCEPTIONS, |
| 304 | 328 | 'section' => __('False positives', 'cryptx'), |
| 305 | 329 | 'label' => __('Endings that are not addresses', 'cryptx'), |
| 306 | - '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. It cannot be used to exempt a particular address -- use the list of posts above for that.', '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'), | |
| 307 | 331 | ], |
| 308 | 332 | 'disable_rss' => [ |
| 309 | 333 | 'type' => 'boolean', |
| 310 | 334 | 'default' => true, |
| @@ -320,9 +344,9 @@ | ||
| 320 | 344 | 'default' => 10000, |
| 321 | 345 | 'tab' => self::TAB_ADVANCED, |
| 322 | 346 | 'section' => __('Encryption', 'cryptx'), |
| 323 | 347 | 'label' => __('Key strengthening', 'cryptx'), |
| 324 | - '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.', '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'), | |
| 325 | 349 | 'depends' => ['encryption_mode' => 'secure'], |
| 326 | 350 | 'choices' => [ |
| 327 | 351 | ['value' => 100000, 'label' => __('Thorough (100,000)', 'cryptx')], |
| 328 | 352 | ['value' => 10000, 'label' => __('Balanced (10,000)', 'cryptx')], |
| @@ -374,13 +398,17 @@ | ||
| 374 | 398 | |
| 375 | 399 | /** |
| 376 | 400 | * The schema as the settings screen consumes it, with runtime values filled in. |
| 377 | 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 | + * | |
| 378 | 406 | * @return array<string, mixed> |
| 379 | 407 | */ |
| 380 | - public static function forClient(): array | |
| 408 | + public static function forClient(array $without = []): array | |
| 381 | 409 | { |
| 382 | - $fields = self::fields(); | |
| 410 | + $fields = array_diff_key(self::fields(), array_flip($without)); | |
| 383 | 411 | $out = []; |
| 384 | 412 | foreach ($fields as $key => $definition) { |
| 385 | 413 | $definition['key'] = $key; |
| 386 | 414 | $out[] = $definition; |
| @@ -385,10 +413,22 @@ | ||
| 385 | 413 | $definition['key'] = $key; |
| 386 | 414 | $out[] = $definition; |
| 387 | 415 | } |
| 388 | 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 | + | |
| 389 | 429 | return [ |
| 390 | - 'tabs' => self::tabs(), | |
| 430 | + 'tabs' => $tabs, | |
| 391 | 431 | 'fields' => $out, |
| 392 | 432 | ]; |
| 393 | 433 | } |
| 394 | 434 | |
| @@ -503,8 +543,52 @@ | ||
| 503 | 543 | return absint($value); |
| 504 | 544 | |
| 505 | 545 | case 'htmlid': |
| 506 | 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)); | |
| 507 | 591 | |
| 508 | 592 | case 'idlist': |
| 509 | 593 | $ids = array_filter(array_map('absint', explode(',', (string) $value))); |
| 510 | 594 | sort($ids); |