PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / network / hop.php

hop.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin trunk, at includes/network/hop.php

640 lines 23.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — The hop token: login on arrival across installs.
4 *
5 * A switch to another install is a navigation, and the browser carries
6 * no login across installs. So the install the user leaves vouches for
7 * them in the one channel the browser cannot block, the URL: a token it
8 * signs with its own key, carrying who the user is there (their user
9 * id, which they cannot edit) and which install the token is for, for
10 * sixty seconds and once. The target verifies the signature against
11 * the key it pinned for that issuer when the two were paired, and logs
12 * in the local account that user has LINKED to, if nobody is logged in
13 * there.
14 *
15 * The link is the whole point. An email is not proof of anything: on
16 * the issuing install a user can set their own email to whatever they
17 * like, an administrator's on the target included, so a token can only
18 * ever name a source account, never claim a target one. A target
19 * account is claimed once, by the person who holds it: arriving with a
20 * token while logged in on the target offers to link the two, and the
21 * accept is a nonced request from that logged-in session. From then on
22 * a token from that source account logs that target account in. The
23 * link is a row of user meta the target owns and can undo in the
24 * Network window.
25 *
26 * A site of the same install needs none of this and never mints one;
27 * a separate install on the same origin does, since it shares nothing
28 * but a hostname. See docs/network.md.
29 *
30 * @package OpenStation
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /** How long a minted token may be spent, in seconds. */
36 const OPENSTATION_NETWORK_HOP_TTL = 60;
37
38 /** Clock skew tolerated between two installs, in seconds. */
39 const OPENSTATION_NETWORK_HOP_SKEW = 60;
40
41 /** User meta, one row per linked source account: `<issuer id>|<source user id>`. */
42 const OPENSTATION_NETWORK_LINK_META = 'openstation_network_link';
43
44 /** User meta: the labels of those links, keyed the same way, for the window that lists them. */
45 const OPENSTATION_NETWORK_LINK_LABELS_META = 'openstation_network_link_labels';
46
47 /** User meta: link keys the user declined, so they are not asked again. */
48 const OPENSTATION_NETWORK_LINK_DECLINED_META = 'openstation_network_link_declined';
49
50 /** How long an offer to link waits for the user's answer, in seconds. */
51 const OPENSTATION_NETWORK_LINK_OFFER_TTL = 10 * MINUTE_IN_SECONDS;
52
53 /**
54 * URL-safe base64, no padding.
55 *
56 * @param string $bin Bytes.
57 * @return string
58 */
59 function openstation_network_hop_encode( $bin ) {
60 return sodium_bin2base64( (string) $bin, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
61 }
62
63 /**
64 * The inverse of {@see openstation_network_hop_encode()}, or null.
65 *
66 * @param string $text Encoded.
67 * @return string|null
68 */
69 function openstation_network_hop_decode( $text ) {
70 try {
71 return sodium_base642bin( (string) $text, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
72 } catch ( SodiumException $e ) {
73 return null;
74 }
75 }
76
77 /**
78 * The origin (scheme, host, port) of a URL, lowercased, or ''.
79 *
80 * @param string $url URL.
81 * @return string
82 */
83 function openstation_network_origin( $url ) {
84 $parts = wp_parse_url( (string) $url );
85 if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
86 return '';
87 }
88 $origin = strtolower( $parts['scheme'] . '://' . $parts['host'] );
89 if ( ! empty( $parts['port'] ) ) {
90 $origin .= ':' . (int) $parts['port'];
91 }
92 return $origin;
93 }
94
95 /**
96 * The shells a token may be minted for, keyed by shell URL, each with
97 * the identity URL of the install it belongs to (the token's audience).
98 * Only entries of OTHER installs: a token is a login credential, and a
99 * site of this very install shares its login already. Nothing beyond
100 * what the switcher offers, either. On a hub those are its members; on
101 * a member, the hub's sites, the hub's network admin and the other
102 * members, everything but itself.
103 *
104 * Origin is not the line: two installs at `example.test/a/` and
105 * `example.test/b/` share a hostname and nothing else, so what makes
106 * an entry foreign is the install behind it, never its origin.
107 *
108 * @return array<string,string>
109 */
110 function openstation_network_hop_targets() {
111 $targets = array();
112 if ( is_multisite() || openstation_network_is_hub() ) {
113 foreach ( openstation_network_members() as $member ) {
114 if ( '' !== $member['shellUrl'] ) {
115 $targets[ $member['shellUrl'] ] = $member['url'];
116 }
117 }
118 return $targets;
119 }
120 $hub = openstation_network_hub();
121 if ( null === $hub || null === $hub['list'] ) {
122 return $targets;
123 }
124 $me = openstation_network_public_key();
125 foreach ( $hub['list']['sites'] as $site ) {
126 if ( '' === $site['shellUrl'] || ( '' !== $site['publicKey'] && hash_equals( $site['publicKey'], $me ) ) ) {
127 continue;
128 }
129 $install = 'member' === $site['kind'] ? $site['url'] : $hub['url'];
130 if ( '' !== $install ) {
131 $targets[ $site['shellUrl'] ] = $install;
132 }
133 }
134 if ( ! empty( $hub['list']['networkAdmin']['shellUrl'] ) ) {
135 $targets[ $hub['list']['networkAdmin']['shellUrl'] ] = $hub['url'];
136 }
137 return $targets;
138 }
139
140 /**
141 * Mint a token for the current user towards a target shell.
142 *
143 * @param string $target The target's shell URL, as the switcher carries it.
144 * @param string $direction `next`, `prev`, or ''.
145 * @return array{token:string,url:string}|WP_Error
146 */
147 function openstation_network_mint_hop( $target, $direction = '' ) {
148 $target = (string) $target;
149 $targets = openstation_network_hop_targets();
150 if ( ! isset( $targets[ $target ] ) ) {
151 return new WP_Error( 'openstation_hop_target', __( 'That is not another install of this network.', 'desktop-mode' ), array( 'status' => 400 ) );
152 }
153 if ( ! openstation_network_url_allowed( $target ) ) {
154 return new WP_Error( 'openstation_hop_insecure', __( 'A login token only travels over HTTPS.', 'desktop-mode' ), array( 'status' => 400 ) );
155 }
156 $user = wp_get_current_user();
157 if ( ! $user || ! $user->exists() ) {
158 return new WP_Error( 'openstation_hop_no_user', __( 'A token needs a logged-in user.', 'desktop-mode' ), array( 'status' => 401 ) );
159 }
160 $now = time();
161 $payload = array(
162 'v' => 2,
163 'iss' => openstation_network_identity()['url'],
164 'aud' => $targets[ $target ],
165 'sub' => (string) $user->ID,
166 'email' => (string) $user->user_email,
167 'name' => (string) $user->display_name,
168 'dir' => in_array( $direction, array( 'next', 'prev' ), true ) ? $direction : '',
169 'iat' => $now,
170 'exp' => $now + OPENSTATION_NETWORK_HOP_TTL,
171 'jti' => bin2hex( random_bytes( 16 ) ),
172 );
173 $json = wp_json_encode( $payload );
174 $token = openstation_network_hop_encode( $json ) . '.' . openstation_network_hop_encode(
175 sodium_crypto_sign_detached( $json, sodium_base642bin( openstation_network_keypair()['secret'], SODIUM_BASE64_VARIANT_ORIGINAL ) )
176 );
177 return array(
178 'token' => $token,
179 'url' => add_query_arg(
180 array(
181 OPENSTATION_SHELL_OVERVIEW_ARG => '1',
182 OPENSTATION_NETWORK_HOP_ARG => $token,
183 ),
184 $target
185 ),
186 );
187 }
188
189 /**
190 * The key this install pinned for an issuer: its own, its hub's, or a
191 * member's — by the issuer's identity URL. '' when the issuer is nobody
192 * this install trusts.
193 *
194 * @param string $iss Issuer identity URL.
195 * @return string Base64 public key, or ''.
196 */
197 function openstation_network_hop_issuer_key( $iss ) {
198 $id = openstation_network_member_id( $iss );
199 if ( openstation_network_member_id( openstation_network_identity()['url'] ) === $id ) {
200 return openstation_network_public_key();
201 }
202 foreach ( openstation_network_members() as $member ) {
203 if ( openstation_network_member_id( $member['url'] ) === $id ) {
204 return $member['publicKey'];
205 }
206 }
207 $hub = openstation_network_hub();
208 if ( null !== $hub ) {
209 if ( openstation_network_member_id( $hub['url'] ) === $id ) {
210 return $hub['publicKey'];
211 }
212 if ( null !== $hub['list'] ) {
213 foreach ( $hub['list']['sites'] as $site ) {
214 if ( 'member' === $site['kind'] && '' !== $site['publicKey'] && '' !== $site['url'] && openstation_network_member_id( $site['url'] ) === $id ) {
215 return $site['publicKey'];
216 }
217 }
218 }
219 }
220 return '';
221 }
222
223 /**
224 * Verify a token spent on this install: signature by a trusted issuer,
225 * audience, lifetime, and never before. Consumes the token.
226 *
227 * @param string $token The token.
228 * @return array<string,mixed>|WP_Error The payload, or why not.
229 */
230 function openstation_network_verify_hop( $token ) {
231 $parts = explode( '.', (string) $token, 2 );
232 $json = 2 === count( $parts ) ? openstation_network_hop_decode( $parts[0] ) : null;
233 $sig = 2 === count( $parts ) ? openstation_network_hop_decode( $parts[1] ) : null;
234 $data = null !== $json ? json_decode( $json, true ) : null;
235 if ( null === $sig || ! is_array( $data ) || 2 !== ( isset( $data['v'] ) ? (int) $data['v'] : 0 ) ) {
236 return new WP_Error( 'openstation_hop_malformed', __( 'That is not a hop token.', 'desktop-mode' ) );
237 }
238 foreach ( array( 'iss', 'aud', 'sub', 'jti' ) as $key ) {
239 if ( empty( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) {
240 return new WP_Error( 'openstation_hop_malformed', __( 'That is not a hop token.', 'desktop-mode' ) );
241 }
242 }
243 $now = time();
244 $iat = isset( $data['iat'] ) ? (int) $data['iat'] : 0;
245 $exp = isset( $data['exp'] ) ? (int) $data['exp'] : 0;
246 if ( $exp <= 0 || $now > $exp + OPENSTATION_NETWORK_HOP_SKEW || $iat > $now + OPENSTATION_NETWORK_HOP_SKEW ) {
247 return new WP_Error( 'openstation_hop_expired', __( 'That hop token has expired.', 'desktop-mode' ) );
248 }
249 if ( openstation_network_member_id( $data['aud'] ) !== openstation_network_member_id( openstation_network_identity()['url'] ) ) {
250 return new WP_Error( 'openstation_hop_audience', __( 'That hop token was minted for another install.', 'desktop-mode' ) );
251 }
252 $key = openstation_network_hop_issuer_key( $data['iss'] );
253 if ( '' === $key ) {
254 return new WP_Error( 'openstation_hop_issuer', __( 'That hop token comes from a site this one does not trust.', 'desktop-mode' ) );
255 }
256 if ( ! openstation_network_verify( $json, sodium_bin2base64( $sig, SODIUM_BASE64_VARIANT_ORIGINAL ), $key ) ) {
257 return new WP_Error( 'openstation_hop_signature', __( 'That hop token is not signed by the site it names.', 'desktop-mode' ) );
258 }
259 if ( ! openstation_network_hop_claim( $data['jti'], $exp ) ) {
260 return new WP_Error( 'openstation_hop_replay', __( 'That hop token was already spent.', 'desktop-mode' ) );
261 }
262 return $data;
263 }
264
265 /**
266 * Claim a token's id, once, install-wide. An INSERT IGNORE into the
267 * main site's options table: its unique key on `option_name` is the one
268 * atomic primitive every WordPress install has, so two requests racing
269 * on the same token cannot both win, and on a multisite every site
270 * shares that one table, where a per-site transient would let each
271 * site of the origin spend the same token once more. Ids no token
272 * could still carry are swept on the way; a row nothing reads back
273 * needs no cache.
274 *
275 * @param string $jti Token id.
276 * @param int $exp The token's expiry, kept so the sweep knows when the row is dead.
277 * @return bool Whether this request claimed it.
278 */
279 function openstation_network_hop_claim( $jti, $exp ) {
280 global $wpdb;
281 $dead = time() - OPENSTATION_NETWORK_HOP_SKEW;
282 // phpcs:disable WordPress.DB.DirectDatabaseQuery -- The unique key IS the check; there is no option cache to keep in step for rows nothing reads.
283 $wpdb->query(
284 $wpdb->prepare(
285 "DELETE FROM {$wpdb->base_prefix}options WHERE option_name LIKE %s AND option_value < %d",
286 $wpdb->esc_like( 'openstation_hop_' ) . '%',
287 $dead
288 )
289 );
290 $won = $wpdb->query(
291 $wpdb->prepare(
292 "INSERT IGNORE INTO {$wpdb->base_prefix}options (option_name, option_value, autoload) VALUES (%s, %s, 'off')",
293 'openstation_hop_' . md5( (string) $jti ),
294 (string) (int) $exp
295 )
296 );
297 // phpcs:enable WordPress.DB.DirectDatabaseQuery
298 return 1 === (int) $won;
299 }
300
301 /**
302 * The key a source account is linked under: the issuer's id (the same
303 * one the registry derives from its URL) and the user's id there.
304 *
305 * @param string $iss Issuer identity URL.
306 * @param string $sub The user's id on the issuer.
307 * @return string
308 */
309 function openstation_network_link_key( $iss, $sub ) {
310 return openstation_network_member_id( $iss ) . '|' . (string) $sub;
311 }
312
313 /**
314 * The local user a verified token logs in: the one who linked that
315 * source account to theirs, and nobody else. Never an email match — an
316 * email is editable on the issuer, so it proves nothing about who
317 * holds an account here.
318 *
319 * @param array<string,mixed> $payload Verified payload.
320 * @return WP_User|null
321 */
322 function openstation_network_hop_user( array $payload ) {
323 $ids = get_users(
324 array(
325 'meta_key' => OPENSTATION_NETWORK_LINK_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One exact row per link; this IS the index.
326 'meta_value' => openstation_network_link_key( (string) $payload['iss'], (string) $payload['sub'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
327 'number' => 1,
328 'fields' => 'ID',
329 'blog_id' => 0,
330 )
331 );
332 $user = $ids ? get_user_by( 'id', (int) $ids[0] ) : false;
333 return $user instanceof WP_User ? $user : null;
334 }
335
336 /**
337 * The name this install knows an issuer by: its hub, a member, a site
338 * of the list, or the issuer's host when it is none of those.
339 *
340 * @param string $iss Issuer identity URL.
341 * @return string
342 */
343 function openstation_network_issuer_name( $iss ) {
344 $id = openstation_network_member_id( $iss );
345 foreach ( openstation_network_members() as $member ) {
346 if ( openstation_network_member_id( $member['url'] ) === $id ) {
347 return $member['name'];
348 }
349 }
350 $hub = openstation_network_hub();
351 if ( null !== $hub ) {
352 if ( openstation_network_member_id( $hub['url'] ) === $id ) {
353 return $hub['name'];
354 }
355 foreach ( null !== $hub['list'] ? $hub['list']['sites'] : array() as $site ) {
356 if ( '' !== $site['url'] && openstation_network_member_id( $site['url'] ) === $id ) {
357 return $site['name'];
358 }
359 }
360 }
361 return (string) wp_parse_url( $iss, PHP_URL_HOST );
362 }
363
364 /**
365 * Link a source account to a local user, so a token from it logs that
366 * user in. The caller has established that the person holds both: they
367 * arrived with the token while logged in here, and accepted from that
368 * session.
369 *
370 * @param int $user_id Local user.
371 * @param array<string,mixed> $offer `iss`, `sub`, `name`, `email`, `site`.
372 * @return bool Whether a link was added (false when it already was).
373 */
374 function openstation_network_link( $user_id, array $offer ) {
375 $key = openstation_network_link_key( (string) $offer['iss'], (string) $offer['sub'] );
376 if ( in_array( $key, openstation_network_links( $user_id ), true ) ) {
377 return false;
378 }
379 add_user_meta( $user_id, OPENSTATION_NETWORK_LINK_META, $key );
380 $labels = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_LABELS_META, true );
381 $labels = is_array( $labels ) ? $labels : array();
382 $labels[ $key ] = array(
383 'site' => (string) $offer['site'],
384 'name' => (string) $offer['name'],
385 'email' => (string) $offer['email'],
386 );
387 update_user_meta( $user_id, OPENSTATION_NETWORK_LINK_LABELS_META, $labels );
388 return true;
389 }
390
391 /**
392 * The keys of a user's linked source accounts.
393 *
394 * @param int $user_id Local user.
395 * @return string[]
396 */
397 function openstation_network_links( $user_id ) {
398 $rows = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_META );
399 return array_values( array_filter( array_map( 'strval', is_array( $rows ) ? $rows : array() ) ) );
400 }
401
402 /**
403 * A user's linked source accounts as the Network window lists them.
404 *
405 * @param int $user_id Local user.
406 * @return array<string,array{site:string,name:string,email:string}> Keyed by link key.
407 */
408 function openstation_network_linked_accounts( $user_id ) {
409 $labels = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_LABELS_META, true );
410 $labels = is_array( $labels ) ? $labels : array();
411 $out = array();
412 foreach ( openstation_network_links( $user_id ) as $key ) {
413 $label = isset( $labels[ $key ] ) && is_array( $labels[ $key ] ) ? $labels[ $key ] : array();
414 $out[ $key ] = array(
415 'site' => isset( $label['site'] ) ? (string) $label['site'] : '',
416 'name' => isset( $label['name'] ) ? (string) $label['name'] : '',
417 'email' => isset( $label['email'] ) ? (string) $label['email'] : '',
418 );
419 }
420 return $out;
421 }
422
423 /**
424 * Undo a link.
425 *
426 * @param int $user_id Local user.
427 * @param string $key Link key.
428 * @return bool Whether there was one.
429 */
430 function openstation_network_unlink( $user_id, $key ) {
431 if ( ! in_array( (string) $key, openstation_network_links( $user_id ), true ) ) {
432 return false;
433 }
434 delete_user_meta( $user_id, OPENSTATION_NETWORK_LINK_META, (string) $key );
435 $labels = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_LABELS_META, true );
436 if ( is_array( $labels ) ) {
437 unset( $labels[ (string) $key ] );
438 update_user_meta( $user_id, OPENSTATION_NETWORK_LINK_LABELS_META, $labels );
439 }
440 return true;
441 }
442
443 /**
444 * Offer a logged-in user the link a token could not use yet: kept for
445 * a few minutes, for the shell to ask about and the link route to act
446 * on. Not offered again once declined.
447 *
448 * @param int $user_id The user logged in here.
449 * @param array<string,mixed> $payload Verified payload.
450 */
451 function openstation_network_offer_link( $user_id, array $payload ) {
452 $key = openstation_network_link_key( (string) $payload['iss'], (string) $payload['sub'] );
453 $declined = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_DECLINED_META, true );
454 if ( is_array( $declined ) && in_array( $key, $declined, true ) ) {
455 return;
456 }
457 set_transient(
458 'openstation_hop_offer_' . (int) $user_id,
459 array(
460 'iss' => (string) $payload['iss'],
461 'sub' => (string) $payload['sub'],
462 'name' => isset( $payload['name'] ) ? sanitize_text_field( (string) $payload['name'] ) : '',
463 'email' => isset( $payload['email'] ) ? sanitize_email( (string) $payload['email'] ) : '',
464 'site' => openstation_network_issuer_name( (string) $payload['iss'] ),
465 ),
466 OPENSTATION_NETWORK_LINK_OFFER_TTL
467 );
468 }
469
470 /**
471 * The offer waiting for the current user, as the shell config carries
472 * it: what to show, and where to answer. Null when there is none.
473 *
474 * @return array{site:string,name:string,email:string,url:string}|null
475 */
476 function openstation_network_link_offer() {
477 if ( ! is_user_logged_in() ) {
478 return null;
479 }
480 $offer = get_transient( 'openstation_hop_offer_' . get_current_user_id() );
481 if ( ! is_array( $offer ) || empty( $offer['iss'] ) || empty( $offer['sub'] ) ) {
482 return null;
483 }
484 return array(
485 'site' => (string) $offer['site'],
486 'name' => (string) $offer['name'],
487 'email' => (string) $offer['email'],
488 'url' => esc_url_raw( rest_url( 'desktop-mode/v1/network/link' ) ),
489 );
490 }
491
492 /**
493 * Answer the offer: link, or decline for good. The nonced request from
494 * the logged-in session is the proof the link needs.
495 *
496 * @param int $user_id The user answering.
497 * @param bool $accept Yes or no.
498 * @return array{linked:bool}|WP_Error
499 */
500 function openstation_network_answer_link( $user_id, $accept ) {
501 $name = 'openstation_hop_offer_' . (int) $user_id;
502 $offer = get_transient( $name );
503 if ( ! is_array( $offer ) || empty( $offer['iss'] ) || empty( $offer['sub'] ) ) {
504 return new WP_Error( 'openstation_hop_no_offer', __( 'There is nothing to link right now.', 'desktop-mode' ), array( 'status' => 404 ) );
505 }
506 delete_transient( $name );
507 if ( $accept ) {
508 openstation_network_link( $user_id, $offer );
509 return array( 'linked' => true );
510 }
511 $declined = get_user_meta( $user_id, OPENSTATION_NETWORK_LINK_DECLINED_META, true );
512 $declined = is_array( $declined ) ? $declined : array();
513 $declined[] = openstation_network_link_key( (string) $offer['iss'], (string) $offer['sub'] );
514 update_user_meta( $user_id, OPENSTATION_NETWORK_LINK_DECLINED_META, array_values( array_unique( $declined ) ) );
515 return array( 'linked' => false );
516 }
517
518 /**
519 * Where the target lands after the token is spent: this request's URL
520 * without the token, with the slide direction the token carried.
521 *
522 * @param string $direction `next`, `prev`, or ''.
523 * @return string
524 */
525 function openstation_network_hop_landing( $direction = '' ) {
526 // The direction on the landing URL is the token's, never the
527 // request's: a caller-supplied one goes with the token.
528 $args = array(
529 OPENSTATION_NETWORK_HOP_ARG => false,
530 OPENSTATION_NETWORK_HOP_FROM_ARG => false,
531 );
532 if ( in_array( $direction, array( 'next', 'prev' ), true ) ) {
533 $args[ OPENSTATION_NETWORK_HOP_FROM_ARG ] = $direction;
534 }
535 return add_query_arg( $args );
536 }
537
538 /**
539 * Spend a token on the shell screen: log in the user who linked that
540 * source account, if nobody is logged in; offer the link to whoever is
541 * logged in when there is none yet; and move on to the clean URL. On
542 * `init`, which in wp-admin runs before `auth_redirect()` gets a chance
543 * to send an anonymous request to the login screen. A token that fails
544 * is dropped the same way, silently: the user lands where they would
545 * have without it.
546 */
547 function openstation_network_redeem_hop() {
548 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- The token IS the credential; every other arg is read-only routing.
549 if ( ! is_admin() || empty( $_GET[ OPENSTATION_NETWORK_HOP_ARG ] ) || ! is_scalar( $_GET[ OPENSTATION_NETWORK_HOP_ARG ] ) ) {
550 return;
551 }
552 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
553 $page = isset( $_GET['page'] ) && is_scalar( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
554 if ( 'admin.php' !== $pagenow || OPENSTATION_SHELL_PAGE_SLUG !== $page ) {
555 return;
556 }
557 $token = sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_NETWORK_HOP_ARG ] ) );
558 // phpcs:enable WordPress.Security.NonceVerification.Recommended
559
560 $payload = openstation_network_verify_hop( $token );
561 $direction = '';
562 if ( ! is_wp_error( $payload ) ) {
563 $direction = isset( $payload['dir'] ) ? (string) $payload['dir'] : '';
564 $linked = openstation_network_hop_user( $payload );
565 if ( $linked && ! is_user_logged_in() ) {
566 wp_set_auth_cookie( $linked->ID, false );
567 } elseif ( ! $linked && is_user_logged_in() ) {
568 openstation_network_offer_link( get_current_user_id(), $payload );
569 }
570 }
571 wp_safe_redirect( openstation_network_hop_landing( $direction ) );
572 exit;
573 }
574 add_action( 'init', 'openstation_network_redeem_hop', 5 );
575
576 /**
577 * Register the mint and link routes.
578 */
579 function openstation_network_register_hop_route() {
580 register_rest_route(
581 'desktop-mode/v1',
582 '/network/link',
583 array(
584 'methods' => WP_REST_Server::CREATABLE,
585 'callback' => 'openstation_rest_network_link',
586 'permission_callback' => 'openstation_rest_require_enabled',
587 'args' => array(
588 'accept' => array(
589 'required' => true,
590 'type' => 'boolean',
591 ),
592 ),
593 )
594 );
595 register_rest_route(
596 'desktop-mode/v1',
597 '/network/hop',
598 array(
599 'methods' => WP_REST_Server::CREATABLE,
600 'callback' => 'openstation_rest_network_hop',
601 'permission_callback' => 'openstation_rest_require_enabled',
602 'args' => array(
603 'target' => array(
604 'required' => true,
605 'type' => 'string',
606 ),
607 'direction' => array(
608 'type' => 'string',
609 'enum' => array( 'next', 'prev', '' ),
610 'default' => '',
611 ),
612 ),
613 )
614 );
615 }
616 add_action( 'rest_api_init', 'openstation_network_register_hop_route' );
617
618 /**
619 * POST /desktop-mode/v1/network/hop
620 *
621 * @param WP_REST_Request $request Request.
622 * @return WP_REST_Response|WP_Error
623 */
624 function openstation_rest_network_hop( WP_REST_Request $request ) {
625 $minted = openstation_network_mint_hop( (string) $request->get_param( 'target' ), (string) $request->get_param( 'direction' ) );
626 return is_wp_error( $minted ) ? $minted : rest_ensure_response( $minted );
627 }
628
629 /**
630 * POST /desktop-mode/v1/network/link — answer the offer waiting for
631 * the current user.
632 *
633 * @param WP_REST_Request $request Request.
634 * @return WP_REST_Response|WP_Error
635 */
636 function openstation_rest_network_link( WP_REST_Request $request ) {
637 $answer = openstation_network_answer_link( get_current_user_id(), (bool) $request->get_param( 'accept' ) );
638 return is_wp_error( $answer ) ? $answer : rest_ensure_response( $answer );
639 }
640