PluginProbe
CryptX / 4.2.1
CryptX v4.2.1
4.2.1 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 All 93 releases
← All changes | classes/Admin/RestController.php +185 -41 4.1.14.2.1 View file →
@@ -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.