PluginProbe
CryptX / trunk
CryptX vtrunk
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 2.4.5 All 92 releases
cryptx / classes / Admin / RestController.php

RestController.php in CryptX trunk, at classes/Admin/RestController.php

411 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX\Admin;
4
5 use CryptX\CryptX;
6 use CryptX\Exposure;
7 use WP_Error;
8 use WP_REST_Request;
9 use WP_REST_Response;
10 use WP_REST_Server;
11
12 /**
13 * The endpoints the settings screen talks to.
14 *
15 * Four routes, all behind the same gate: the capability that guards the
16 * settings page itself. WordPress checks the REST nonce before any of this
17 * runs, and apiFetch in the browser sends it automatically.
18 *
19 * @package CryptX
20 * @since 4.1.0
21 */
22 final class RestController
23 {
24 private const NAMESPACE = 'cryptx/v1';
25
26 /**
27 * Hooks the routes in.
28 *
29 * @return void
30 */
31 public function register(): void
32 {
33 add_action('rest_api_init', [$this, 'registerRoutes']);
34 }
35
36 /**
37 * Declares the four routes.
38 *
39 * @return void
40 */
41 public function registerRoutes(): void
42 {
43 register_rest_route(self::NAMESPACE, '/settings', [
44 [
45 'methods' => WP_REST_Server::READABLE,
46 'callback' => [$this, 'getSettings'],
47 'permission_callback' => [$this, 'checkPermission'],
48 ],
49 [
50 'methods' => WP_REST_Server::EDITABLE,
51 'callback' => [$this, 'saveSettings'],
52 'permission_callback' => [$this, 'checkPermission'],
53 'args' => [
54 'values' => [
55 'required' => true,
56 'type' => 'object',
57 ],
58 ],
59 ],
60 ]);
61
62 register_rest_route(self::NAMESPACE, '/preview', [
63 'methods' => WP_REST_Server::CREATABLE,
64 'callback' => [$this, 'getPreview'],
65 'permission_callback' => [$this, 'checkPermission'],
66 'args' => [
67 'values' => [
68 'required' => true,
69 'type' => 'object',
70 ],
71 ],
72 ]);
73
74 register_rest_route(self::NAMESPACE, '/settings/reset', [
75 'methods' => WP_REST_Server::CREATABLE,
76 'callback' => [$this, 'resetSettings'],
77 'permission_callback' => [$this, 'checkPermission'],
78 ]);
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
110 register_rest_route(self::NAMESPACE, '/changelog', [
111 'methods' => WP_REST_Server::READABLE,
112 'callback' => [$this, 'getChangelog'],
113 'permission_callback' => [$this, 'checkPermission'],
114 ]);
115 }
116
117 /**
118 * The most recent releases, for the help tab.
119 *
120 * @return WP_REST_Response
121 */
122 public function getChangelog(): WP_REST_Response
123 {
124 return new WP_REST_Response([
125 'releases' => Changelog::recent(),
126 // So the screen can say which of these the site is actually running.
127 // "What changed" is only useful next to "since when".
128 'current' => CRYPTX_VERSION,
129 ]);
130 }
131
132 /**
133 * The same capability that guards the settings page.
134 *
135 * @return true|WP_Error
136 */
137 public function checkPermission()
138 {
139 if (current_user_can('manage_options')) {
140 return true;
141 }
142
143 return new WP_Error(
144 'cryptx_forbidden',
145 __('You do not have sufficient permissions to manage CryptX settings.', 'cryptx'),
146 ['status' => rest_authorization_required_code()]
147 );
148 }
149
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 /**
216 * Current values plus the schema that describes them.
217 *
218 * @return WP_REST_Response
219 */
220 public function getSettings(): WP_REST_Response
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
229 return new WP_REST_Response([
230 'values' => $this->currentValues(),
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()),
241 ]);
242 }
243
244 /**
245 * Stores the submitted values.
246 *
247 * @param WP_REST_Request $request The request.
248 *
249 * @return WP_REST_Response
250 */
251 public function saveSettings(WP_REST_Request $request): WP_REST_Response
252 {
253 $incoming = (array) $request->get_param('values');
254 $clean = SettingsSchema::sanitize($incoming);
255
256 // Read fresh and merge, rather than writing the submitted set wholesale:
257 // the stored array also holds keys this screen never shows, and they
258 // have to survive a save untouched.
259 $stored = get_option('cryptX', []);
260 if (!is_array($stored)) {
261 $stored = [];
262 }
263
264 update_option('cryptX', array_merge($stored, $clean));
265
266 return new WP_REST_Response([
267 'values' => $this->currentValues(),
268 'message' => __('Settings saved.', 'cryptx'),
269 ]);
270 }
271
272 /**
273 * Puts every editable option back to its default.
274 *
275 * @return WP_REST_Response
276 */
277 public function resetSettings(): WP_REST_Response
278 {
279 $stored = get_option('cryptX', []);
280 if (!is_array($stored)) {
281 $stored = [];
282 }
283
284 $defaults = SettingsSchema::sanitize(SettingsSchema::defaults());
285 update_option('cryptX', array_merge($stored, $defaults));
286
287 return new WP_REST_Response([
288 'values' => $this->currentValues(),
289 'message' => __('Settings restored to their defaults.', 'cryptx'),
290 ]);
291 }
292
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 /**
352 * Renders the sample address with the values as they stand in the form.
353 *
354 * @param WP_REST_Request $request The request.
355 *
356 * @return WP_REST_Response
357 */
358 public function getPreview(WP_REST_Request $request): WP_REST_Response
359 {
360 $overrides = SettingsSchema::sanitize((array) $request->get_param('values'));
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
371 $sample = sprintf(
372 /* translators: %s: a sample email address */
373 __('Write to %s if you have any questions.', 'cryptx'),
374 Exposure::SAMPLE_ADDRESS
375 );
376
377 $markup = CryptX::get_instance()->renderPreviewMarkup($overrides, $sample);
378
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);
383
384 return new WP_REST_Response([
385 'markup' => $markup,
386 'plain' => $sample,
387 'exposure' => $exposure,
388 // Kept so an older cached copy of the screen still shows something
389 // sensible rather than nothing.
390 'leaks' => $exposure === Exposure::PLAIN,
391 ]);
392 }
393
394 /**
395 * The stored values, limited to the keys the screen knows about.
396 *
397 * @return array<string, mixed>
398 */
399 private function currentValues(): array
400 {
401 $stored = CryptX::get_instance()->loadCryptXOptionsWithDefaults();
402 $values = [];
403
404 foreach (SettingsSchema::defaults() as $key => $default) {
405 $values[$key] = $stored[$key] ?? $default;
406 }
407
408 return $values;
409 }
410 }
411