| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace CryptX\Admin; |
| 4 | 4 | |
| 5 | 5 | use CryptX\CryptX; |
| 6 | +use CryptX\Exposure; | |
| 6 | 7 | use WP_Error; |
| 7 | 8 | use WP_REST_Request; |
| 8 | 9 | use WP_REST_Response; |
| 9 | 10 | use WP_REST_Server; |
| @@ -22,14 +23,8 @@ | ||
| 22 | 23 | { |
| 23 | 24 | private const NAMESPACE = 'cryptx/v1'; |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | - * The address used in the preview. RFC 2606 reserves example.com, so this | |
| 27 | - * can never be a real person's address. | |
| 28 | - */ | |
| 29 | - private const SAMPLE_ADDRESS = 'info@example.com'; | |
| 30 | - | |
| 31 | - /** | |
| 32 | 27 | * Hooks the routes in. |
| 33 | 28 | * |
| 34 | 29 | * @return void |
| 35 | 30 | */ |
| @@ -81,8 +76,38 @@ | ||
| 81 | 76 | 'callback' => [$this, 'resetSettings'], |
| 82 | 77 | 'permission_callback' => [$this, 'checkPermission'], |
| 83 | 78 | ]); |
| 84 | 79 | |
| 80 | + register_rest_route(self::NAMESPACE, '/secrets/rotate', [ | |
| 81 | + 'methods' => WP_REST_Server::CREATABLE, | |
| 82 | + 'callback' => [$this, 'rotateSecrets'], | |
| 83 | + 'permission_callback' => [$this, 'checkPermission'], | |
| 84 | + ]); | |
| 85 | + | |
| 86 | + // Only on a network, and behind a different capability: these are the | |
| 87 | + // defaults a new site starts with, which is a network administrator's | |
| 88 | + // decision and not a site administrator's. | |
| 89 | + if (is_multisite()) { | |
| 90 | + register_rest_route(self::NAMESPACE, '/network-defaults', [ | |
| 91 | + [ | |
| 92 | + 'methods' => WP_REST_Server::READABLE, | |
| 93 | + 'callback' => [$this, 'getNetworkDefaults'], | |
| 94 | + 'permission_callback' => [$this, 'checkNetworkPermission'], | |
| 95 | + ], | |
| 96 | + [ | |
| 97 | + 'methods' => WP_REST_Server::EDITABLE, | |
| 98 | + 'callback' => [$this, 'saveNetworkDefaults'], | |
| 99 | + 'permission_callback' => [$this, 'checkNetworkPermission'], | |
| 100 | + 'args' => [ | |
| 101 | + 'values' => [ | |
| 102 | + 'required' => true, | |
| 103 | + 'type' => 'object', | |
| 104 | + ], | |
| 105 | + ], | |
| 106 | + ], | |
| 107 | + ]); | |
| 108 | + } | |
| 109 | + | |
| 85 | 110 | register_rest_route(self::NAMESPACE, '/changelog', [ |
| 86 | 111 | 'methods' => WP_REST_Server::READABLE, |
| 87 | 112 | 'callback' => [$this, 'getChangelog'], |
| 88 | 113 | 'permission_callback' => [$this, 'checkPermission'], |
| @@ -122,8 +147,73 @@ | ||
| 122 | 147 | ); |
| 123 | 148 | } |
| 124 | 149 | |
| 125 | 150 | /** |
| 151 | + * The capability that guards the network defaults. | |
| 152 | + * | |
| 153 | + * Deliberately not the same one: a site administrator may configure their | |
| 154 | + * own site, and that is what manage_options is for. Deciding what every | |
| 155 | + * future site starts with is a different question, and on a network only a | |
| 156 | + * super administrator holds it. | |
| 157 | + * | |
| 158 | + * @return true|WP_Error | |
| 159 | + */ | |
| 160 | + public function checkNetworkPermission() | |
| 161 | + { | |
| 162 | + if (is_multisite() && current_user_can('manage_network_options')) { | |
| 163 | + return true; | |
| 164 | + } | |
| 165 | + | |
| 166 | + return new WP_Error( | |
| 167 | + 'cryptx_forbidden', | |
| 168 | + __('You do not have sufficient permissions to manage the network defaults.', 'cryptx'), | |
| 169 | + ['status' => rest_authorization_required_code()] | |
| 170 | + ); | |
| 171 | + } | |
| 172 | + | |
| 173 | + /** | |
| 174 | + * The defaults a newly created site starts with. | |
| 175 | + * | |
| 176 | + * @return WP_REST_Response | |
| 177 | + */ | |
| 178 | + public function getNetworkDefaults(): WP_REST_Response | |
| 179 | + { | |
| 180 | + return new WP_REST_Response([ | |
| 181 | + 'values' => array_merge( | |
| 182 | + array_diff_key( | |
| 183 | + SettingsSchema::defaults(), | |
| 184 | + array_flip(NetworkDefaults::notShareable()) | |
| 185 | + ), | |
| 186 | + NetworkDefaults::get() | |
| 187 | + ), | |
| 188 | + 'schema' => SettingsSchema::forClient(NetworkDefaults::notShareable()), | |
| 189 | + ]); | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** | |
| 193 | + * Stores the defaults a newly created site starts with. | |
| 194 | + * | |
| 195 | + * @param WP_REST_Request $request The request. | |
| 196 | + * | |
| 197 | + * @return WP_REST_Response | |
| 198 | + */ | |
| 199 | + public function saveNetworkDefaults(WP_REST_Request $request): WP_REST_Response | |
| 200 | + { | |
| 201 | + NetworkDefaults::save((array) $request->get_param('values')); | |
| 202 | + | |
| 203 | + return new WP_REST_Response([ | |
| 204 | + 'values' => array_merge( | |
| 205 | + array_diff_key( | |
| 206 | + SettingsSchema::defaults(), | |
| 207 | + array_flip(NetworkDefaults::notShareable()) | |
| 208 | + ), | |
| 209 | + NetworkDefaults::get() | |
| 210 | + ), | |
| 211 | + 'message' => __('Network defaults saved. Sites that already exist are not changed; these values apply to sites created from now on.', 'cryptx'), | |
| 212 | + ]); | |
| 213 | + } | |
| 214 | + | |
| 215 | + /** | |
| 126 | 216 | * Current values plus the schema that describes them. |
| 127 | 217 | * |
| 128 | 218 | * @return WP_REST_Response |
| 129 | 219 | */ |
| @@ -128,11 +218,27 @@ | ||
| 128 | 218 | * @return WP_REST_Response |
| 129 | 219 | */ |
| 130 | 220 | public function getSettings(): WP_REST_Response |
| 131 | 221 | { |
| 222 | + $config = CryptX::get_instance()->getConfig(); | |
| 223 | + | |
| 224 | + // Housekeeping, here rather than on the image endpoint: that one is | |
| 225 | + // reached by strangers, and a stranger should not decide when this site | |
| 226 | + // writes to its own database. | |
| 227 | + $config->forgetExpiredImageTokenSecret(); | |
| 228 | + | |
| 132 | 229 | return new WP_REST_Response([ |
| 133 | 230 | 'values' => $this->currentValues(), |
| 134 | 231 | 'schema' => SettingsSchema::forClient(), |
| 232 | + // When the secrets were last replaced, so the screen can say it. | |
| 233 | + // A rotation nobody meant to trigger is otherwise invisible. | |
| 234 | + // | |
| 235 | + // Formatted here, not in the browser: toLocaleDateString() uses the | |
| 236 | + // reader's time zone and language, wp_date() the site's. Two dates | |
| 237 | + // in the same card, one of each, would disagree by a day for any | |
| 238 | + // administrator sitting in a different zone from the site -- on a | |
| 239 | + // card whose whole job is to make an unexpected date stand out. | |
| 240 | + 'secretsRotatedAt' => self::formatRotationDate($config->secretsRotatedAt()), | |
| 135 | 241 | ]); |
| 136 | 242 | } |
| 137 | 243 | |
| 138 | 244 | /** |
| @@ -184,8 +290,66 @@ | ||
| 184 | 290 | ]); |
| 185 | 291 | } |
| 186 | 292 | |
| 187 | 293 | /** |
| 294 | + * Replaces both secrets with fresh ones. | |
| 295 | + * | |
| 296 | + * Worth knowing before pressing it, and said on the screen as well: links | |
| 297 | + * already delivered keep working for ever, because the key travels inside | |
| 298 | + * them. Pictures do not -- their token is opened on the server -- so the | |
| 299 | + * replaced image secret is kept for a grace period and the answer says | |
| 300 | + * until when. | |
| 301 | + * | |
| 302 | + * @return WP_REST_Response | |
| 303 | + */ | |
| 304 | + public function rotateSecrets(): WP_REST_Response | |
| 305 | + { | |
| 306 | + $cryptx = CryptX::get_instance(); | |
| 307 | + $config = $cryptx->getConfig(); | |
| 308 | + | |
| 309 | + $config->rotateSecrets(); | |
| 310 | + | |
| 311 | + // The static option list and the Config instance were built when the | |
| 312 | + // request started; without this they would go on serving the replaced | |
| 313 | + // secret for the rest of it, and the preview underneath would render | |
| 314 | + // with a key the site no longer uses. | |
| 315 | + $cryptx->refreshForCurrentSite(); | |
| 316 | + | |
| 317 | + $graceEnds = $cryptx->getConfig()->previousImageTokenSecret() === '' | |
| 318 | + ? 0 | |
| 319 | + : (int) (get_option('cryptX')['image_token_secret_previous_until'] ?? 0); | |
| 320 | + | |
| 321 | + return new WP_REST_Response([ | |
| 322 | + 'values' => $this->currentValues(), | |
| 323 | + 'graceEnds' => $graceEnds, | |
| 324 | + 'secretsRotatedAt' => self::formatRotationDate($cryptx->getConfig()->secretsRotatedAt()), | |
| 325 | + 'message' => $graceEnds > 0 | |
| 326 | + ? sprintf( | |
| 327 | + /* translators: %s: a date */ | |
| 328 | + __('New secrets created. Links already published keep working. Pictures made with the old secret keep working until %s.', 'cryptx'), | |
| 329 | + // wp_date(), not date_i18n(): the latter expects a stamp | |
| 330 | + // that has already been shifted by the site's offset, and | |
| 331 | + // this one comes straight from time(). On a site two hours | |
| 332 | + // ahead the date shown was a day out. | |
| 333 | + wp_date(get_option('date_format'), $graceEnds) | |
| 334 | + ) | |
| 335 | + : __('New secrets created. Links already published keep working.', 'cryptx'), | |
| 336 | + ]); | |
| 337 | + } | |
| 338 | + | |
| 339 | + /** | |
| 340 | + * A rotation date in the site's own time zone and format. | |
| 341 | + * | |
| 342 | + * @param int $timestamp A Unix timestamp, or 0 for "never". | |
| 343 | + * | |
| 344 | + * @return string The formatted date, or an empty string. | |
| 345 | + */ | |
| 346 | + private static function formatRotationDate(int $timestamp): string | |
| 347 | + { | |
| 348 | + return $timestamp > 0 ? wp_date(get_option('date_format'), $timestamp) : ''; | |
| 349 | + } | |
| 350 | + | |
| 351 | + /** | |
| 188 | 352 | * Renders the sample address with the values as they stand in the form. |
| 189 | 353 | * |
| 190 | 354 | * @param WP_REST_Request $request The request. |
| 191 | 355 | * |
| @@ -194,17 +358,29 @@ | ||
| 194 | 358 | public function getPreview(WP_REST_Request $request): WP_REST_Response |
| 195 | 359 | { |
| 196 | 360 | $overrides = SettingsSchema::sanitize((array) $request->get_param('values')); |
| 197 | 361 | |
| 362 | + // The exemption list is switched off for the measurement, exactly as in | |
| 363 | + // the Site Health check. The sample lives at example.com, and both the | |
| 364 | + // field's own help text and the FAQ use "@example.com" as the example | |
| 365 | + // to type -- so an administrator trying the feature out would have | |
| 366 | + // watched the preview declare their working installation readable. | |
| 367 | + // What the preview answers is whether the settings hide an address, not | |
| 368 | + // whether every address on the site is covered. | |
| 369 | + $overrides['exemptAddresses'] = ''; | |
| 370 | + | |
| 198 | 371 | $sample = sprintf( |
| 199 | 372 | /* translators: %s: a sample email address */ |
| 200 | 373 | __('Write to %s if you have any questions.', 'cryptx'), |
| 201 | - self::SAMPLE_ADDRESS | |
| 374 | + Exposure::SAMPLE_ADDRESS | |
| 202 | 375 | ); |
| 203 | 376 | |
| 204 | 377 | $markup = CryptX::get_instance()->renderPreviewMarkup($overrides, $sample); |
| 205 | 378 | |
| 206 | - $exposure = $this->exposure($markup); | |
| 379 | + // CryptX\Exposure and not a method here: the Site Health check needs | |
| 380 | + // the same judgement, and two implementations would eventually | |
| 381 | + // disagree about the same page. | |
| 382 | + $exposure = Exposure::of($markup); | |
| 207 | 383 | |
| 208 | 384 | return new WP_REST_Response([ |
| 209 | 385 | 'markup' => $markup, |
| 210 | 386 | 'plain' => $sample, |
| @@ -210,42 +386,10 @@ | ||
| 210 | 386 | 'plain' => $sample, |
| 211 | 387 | 'exposure' => $exposure, |
| 212 | 388 | // Kept so an older cached copy of the screen still shows something |
| 213 | 389 | // sensible rather than nothing. |
| 214 | - 'leaks' => $exposure === 'plain', | |
| 390 | + 'leaks' => $exposure === Exposure::PLAIN, | |
| 215 | 391 | ]); |
| 216 | - } | |
| 217 | - | |
| 218 | - /** | |
| 219 | - * How exposed the sample address is in the produced markup. | |
| 220 | - * | |
| 221 | - * Three answers, not two. Several of the display options put the address | |
| 222 | - * into the markup as HTML entities -- in an alt text, for instance, where | |
| 223 | - * a screen reader needs it. A search of the raw markup finds nothing there | |
| 224 | - * and the screen used to report "no readable address", which is true of the | |
| 225 | - * bytes and false of the situation: any bot that decodes entities, and most | |
| 226 | - * do, reads it straight off. Saying so is the difference between a preview | |
| 227 | - * and a reassurance. | |
| 228 | - * | |
| 229 | - * @param string $markup The processed markup. | |
| 230 | - * | |
| 231 | - * @return string One of 'none', 'encoded' or 'plain'. | |
| 232 | - */ | |
| 233 | - private function exposure(string $markup): string | |
| 234 | - { | |
| 235 | - $pattern = '/[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})/'; | |
| 236 | - | |
| 237 | - if (preg_match($pattern, $markup)) { | |
| 238 | - return 'plain'; | |
| 239 | - } | |
| 240 | - | |
| 241 | - $decoded = html_entity_decode($markup, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 242 | - | |
| 243 | - if (preg_match($pattern, $decoded)) { | |
| 244 | - return 'encoded'; | |
| 245 | - } | |
| 246 | - | |
| 247 | - return 'none'; | |
| 248 | 392 | } |
| 249 | 393 | |
| 250 | 394 | /** |
| 251 | 395 | * The stored values, limited to the keys the screen knows about. |