| 1 |
<?php |
| 2 |
|
| 3 |
namespace CryptX\Admin; |
| 4 |
|
| 5 |
use CryptX\CryptX; |
| 6 |
use CryptX\Exposure; |
| 7 |
|
| 8 |
/** |
| 9 |
* A Site Health test that answers the only question that matters. |
| 10 |
* |
| 11 |
* The settings screen already judges what the plugin produces -- hidden, merely |
| 12 |
* encoded, or plainly readable -- but only for whoever opens the Appearance tab |
| 13 |
* and looks. Site Health is where someone goes when they want to know whether |
| 14 |
* their site is in order, and until now CryptX said nothing there. A plugin |
| 15 |
* whose whole job is invisible needs to be able to report on itself. |
| 16 |
* |
| 17 |
* The test is deliberately not alarmist. Several settings weaken protection on |
| 18 |
* purpose, and the defaults are among them: feeds are left alone because a feed |
| 19 |
* reader runs no JavaScript. Those are reported as what they are -- a choice |
| 20 |
* with a consequence -- and not as a fault. |
| 21 |
* |
| 22 |
* @package CryptX |
| 23 |
* @since 4.2.0 |
| 24 |
*/ |
| 25 |
final class SiteHealth |
| 26 |
{ |
| 27 |
/** |
| 28 |
* Hooks the test in. |
| 29 |
* |
| 30 |
* @return void |
| 31 |
*/ |
| 32 |
public function register(): void |
| 33 |
{ |
| 34 |
add_filter('site_status_tests', [$this, 'addTest']); |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* Declares the test. |
| 39 |
* |
| 40 |
* @param array<string, mixed> $tests The tests Site Health knows about. |
| 41 |
* |
| 42 |
* @return array<string, mixed> The tests including ours. |
| 43 |
*/ |
| 44 |
public function addTest(array $tests): array |
| 45 |
{ |
| 46 |
$tests['direct']['cryptx_protection'] = [ |
| 47 |
'label' => __('Email address protection', 'cryptx'), |
| 48 |
'test' => [$this, 'run'], |
| 49 |
]; |
| 50 |
|
| 51 |
return $tests; |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Runs the sample through the real chain and reports what came out. |
| 56 |
* |
| 57 |
* @return array<string, mixed> The result in the shape Site Health expects. |
| 58 |
*/ |
| 59 |
public function run(): array |
| 60 |
{ |
| 61 |
$result = [ |
| 62 |
'label' => __('Email addresses are hidden from spam bots', 'cryptx'), |
| 63 |
'status' => 'good', |
| 64 |
'badge' => [ |
| 65 |
'label' => __('Security', 'cryptx'), |
| 66 |
'color' => 'blue', |
| 67 |
], |
| 68 |
'description' => '', |
| 69 |
'actions' => sprintf( |
| 70 |
'<p><a href="%s">%s</a></p>', |
| 71 |
esc_url(admin_url('options-general.php?page=' . SettingsPage::MENU_SLUG)), |
| 72 |
esc_html__('Review the CryptX settings', 'cryptx') |
| 73 |
), |
| 74 |
'test' => 'cryptx_protection', |
| 75 |
]; |
| 76 |
|
| 77 |
$cryptx = CryptX::get_instance(); |
| 78 |
$options = $cryptx->loadCryptXOptionsWithDefaults(); |
| 79 |
|
| 80 |
// The real filter chain on a real sample, not a description of what it |
| 81 |
// ought to do. This is the same path the front end takes. |
| 82 |
// |
| 83 |
// The carrier sentence is NOT translatable. A translation that loses |
| 84 |
// the "%s" produces a sample without an address; PHP 8 discards the |
| 85 |
// surplus argument without complaint, Exposure::of() then finds nothing |
| 86 |
// and the test reports "good" for ever after. A security check that |
| 87 |
// reassures because its own input went missing is worse than no check. |
| 88 |
$sample = 'Write to ' . Exposure::SAMPLE_ADDRESS . ' if you have any questions.'; |
| 89 |
|
| 90 |
// Belt and braces: if the sample ever stops carrying an address, say so |
| 91 |
// instead of reporting success. |
| 92 |
if (Exposure::of($sample) !== Exposure::PLAIN) { |
| 93 |
$result['status'] = 'recommended'; |
| 94 |
$result['badge']['color'] = 'blue'; |
| 95 |
$result['label'] = __('Email address protection could not be checked', 'cryptx'); |
| 96 |
$result['description'] = '<p>' . esc_html__( |
| 97 |
'CryptX could not build its test address, so the check has nothing to judge. This is a fault in the plugin, not in your settings.', |
| 98 |
'cryptx' |
| 99 |
) . '</p>'; |
| 100 |
|
| 101 |
return $result; |
| 102 |
} |
| 103 |
|
| 104 |
// The exemption list is deliberately switched off for the measurement. |
| 105 |
// The sample lives at example.com, so a site that exempts |
| 106 |
// "@example.com" -- a plausible thing to write -- would make its own |
| 107 |
// sample readable and report a working installation as critical. The |
| 108 |
// question here is whether the mechanism works, not whether every |
| 109 |
// address on the site is covered; the exemptions are a choice, and |
| 110 |
// choices are listed below rather than counted against the result. |
| 111 |
$markup = $cryptx->renderPreviewMarkup(['exemptAddresses' => ''], $sample); |
| 112 |
|
| 113 |
$exposure = Exposure::of($markup); |
| 114 |
$notes = $this->settingNotes($options); |
| 115 |
|
| 116 |
// Das Badge bleibt blau. In WordPress selbst steht es 28 von 28 Mal |
| 117 |
// auf 'blue': es benennt die Kategorie ("Security"), nicht das |
| 118 |
// Ergebnis. Das Ergebnis traegt das Symbol und die Einsortierung in |
| 119 |
// "Kritische Probleme" bzw. "Empfohlene Verbesserungen". Ein orangenes |
| 120 |
// Security-Badge daneben liest sich wie eine zweite Kategorie. |
| 121 |
if ($exposure === Exposure::PLAIN) { |
| 122 |
$result['status'] = 'critical'; |
| 123 |
$result['label'] = __('Email addresses are readable in your pages', 'cryptx'); |
| 124 |
$result['description'] = '<p>' . esc_html__( |
| 125 |
'With the current settings an address survives in the delivered HTML exactly as it was written. Any spam bot that reads the page reads the address.', |
| 126 |
'cryptx' |
| 127 |
) . '</p>'; |
| 128 |
} elseif ($exposure === Exposure::ENCODED) { |
| 129 |
$result['status'] = 'recommended'; |
| 130 |
$result['label'] = __('Email addresses are only lightly disguised', 'cryptx'); |
| 131 |
$result['description'] = '<p>' . esc_html__( |
| 132 |
'Addresses reach the page as HTML entities. That defeats a simple scanner, but any spam bot that decodes entities -- and most do -- reads them. Switching the method to JavaScript hides the address until someone clicks.', |
| 133 |
'cryptx' |
| 134 |
) . '</p>'; |
| 135 |
} else { |
| 136 |
$result['description'] = '<p>' . esc_html__( |
| 137 |
'A test address was run through the same filters your pages use. Nothing resembling an address was left in the result.', |
| 138 |
'cryptx' |
| 139 |
) . '</p>'; |
| 140 |
} |
| 141 |
|
| 142 |
if ($notes !== []) { |
| 143 |
// Listed, not counted against the result. Every one of these is a |
| 144 |
// setting somebody chose, and the commonest of them -- feeds left |
| 145 |
// alone -- is the default. Turning a default into a warning is how |
| 146 |
// people learn to ignore warnings, so the status follows the |
| 147 |
// measurement above and nothing else. |
| 148 |
$result['description'] .= '<p>' . esc_html__( |
| 149 |
'These settings deliberately leave some addresses unprotected:', |
| 150 |
'cryptx' |
| 151 |
) . '</p><ul><li>' . implode('</li><li>', array_map('esc_html', $notes)) . '</li></ul>'; |
| 152 |
} |
| 153 |
|
| 154 |
return $result; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* The settings that knowingly leave addresses in the open. |
| 159 |
* |
| 160 |
* @param array<string, mixed> $options The stored options. |
| 161 |
* |
| 162 |
* @return array<int, string> One sentence per setting, or an empty array. |
| 163 |
*/ |
| 164 |
private function settingNotes(array $options): array |
| 165 |
{ |
| 166 |
$notes = []; |
| 167 |
|
| 168 |
if (!empty($options['disable_rss'])) { |
| 169 |
$notes[] = __('RSS feeds are left unprotected, so addresses are readable in your feed. This is the default, because a feed reader runs no JavaScript and a protected link would be dead there.', 'cryptx'); |
| 170 |
} |
| 171 |
|
| 172 |
if (empty($options['autolink'])) { |
| 173 |
$notes[] = __('Plain addresses are not turned into links. They are still disguised, but they never become a working contact link.', 'cryptx'); |
| 174 |
} |
| 175 |
|
| 176 |
$exempt = array_filter(array_map('trim', explode(',', (string) ($options['exemptAddresses'] ?? '')))); |
| 177 |
|
| 178 |
if ($exempt !== []) { |
| 179 |
$notes[] = sprintf( |
| 180 |
/* translators: %d: number of exempt email addresses */ |
| 181 |
_n( |
| 182 |
'%d address is on the list of addresses to leave alone, and stays readable wherever it appears.', |
| 183 |
'%d addresses are on the list of addresses to leave alone, and stay readable wherever they appear.', |
| 184 |
count($exempt), |
| 185 |
'cryptx' |
| 186 |
), |
| 187 |
count($exempt) |
| 188 |
); |
| 189 |
} |
| 190 |
|
| 191 |
$excluded = array_filter(array_map('trim', explode(',', (string) ($options['excludedIDs'] ?? '')))); |
| 192 |
|
| 193 |
if ($excluded !== []) { |
| 194 |
$notes[] = sprintf( |
| 195 |
/* translators: %d: number of excluded posts */ |
| 196 |
_n( |
| 197 |
'%d post or page is excluded from CryptX. Addresses in it stay exactly as written.', |
| 198 |
'%d posts or pages are excluded from CryptX. Addresses in them stay exactly as written.', |
| 199 |
count($excluded), |
| 200 |
'cryptx' |
| 201 |
), |
| 202 |
count($excluded) |
| 203 |
); |
| 204 |
} |
| 205 |
|
| 206 |
// Four whole sentences rather than one with a noun slotted in. A |
| 207 |
// sentence assembled from parts survives translation into German and |
| 208 |
// falls apart in any language that inflects the noun -- the i18n |
| 209 |
// handbook says so, and there are only four of them. |
| 210 |
$switchedOff = [ |
| 211 |
'the_content' => __('CryptX is switched off for posts and pages.', 'cryptx'), |
| 212 |
'the_excerpt' => __('CryptX is switched off for excerpts.', 'cryptx'), |
| 213 |
'comment_text' => __('CryptX is switched off for comments.', 'cryptx'), |
| 214 |
'widget_text' => __('CryptX is switched off for widgets.', 'cryptx'), |
| 215 |
]; |
| 216 |
|
| 217 |
foreach ($switchedOff as $key => $sentence) { |
| 218 |
if (empty($options[$key])) { |
| 219 |
$notes[] = $sentence; |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
return $notes; |
| 224 |
} |
| 225 |
} |
| 226 |
|