api = $api; } /** * Whether this site already holds credentials, from wherever. */ public function is_connected(): bool { return $this->api->has_credentials(); } /** * Whether the credentials come from wp-config.php constants. * * Connecting writes the option, and the constants outrank it — so on such * a site the button would "succeed" and change nothing visible. */ public function credentials_are_constants(): bool { return defined( 'MAPSEMBLE_CLIENT_ID' ) || defined( 'MAPSEMBLE_CLIENT_SECRET' ); } /** * The state for the current connect attempt, generating one if needed. * * Kept in an option rather than in the form, because the value has to * survive the round trip through Mapsemble in another tab and every * re-render of the settings page in between. Regenerated once spent or * stale, so an abandoned attempt cannot be resumed later by whoever * still holds the old link. */ public function pending_state(): string { $pending = $this->stored(); if ( '' !== $pending['state'] ) { return $pending['state']; } // Hex because Mapsemble validates the shape, and 32 bytes because the // state is the only thing standing between a stranger and this // account's client secret. $state = bin2hex( random_bytes( 32 ) ); $value = array( 'state' => $state, 'created' => time(), ); // Autoload off: a bearer for the secret has no business being loaded // on every front-end request. update_option( self::STATE_OPTION, $value, false ); return $state; } /** * The URL a site admin approves this site at. * * @param string $site_url This site's URL, shown on the approval page so * the person approving can see who is asking. */ public function approval_url( string $site_url ): string { return $this->api->get_app_base() . '/wordpress/connect?' . http_build_query( array( 'state' => $this->pending_state(), 'site' => $site_url, ) ); } /** * Collects credentials waiting on Mapsemble and stores them. * * @return bool True when credentials were collected and saved. False means * nothing is waiting yet, which is the ordinary answer while * the admin is still on the Mapsemble page — not an error. */ public function collect(): bool { $state = $this->stored()['state']; if ( '' === $state ) { return false; } $credentials = $this->api->collect_connected_credentials( $state ); if ( null === $credentials ) { return false; } $saved = get_option( 'mapsemble_settings', array() ); $saved = is_array( $saved ) ? $saved : array(); update_option( 'mapsemble_settings', array_merge( $saved, array( // The same treatment a manual save gives the pair: the id // is plain text, the secret is opaque and only trimmed. 'client_id' => sanitize_text_field( $credentials['client_id'] ), 'client_secret' => trim( $credentials['client_secret'] ), ) ) ); // The caches belong to whichever account the previous credentials // named — the same reason a manual save drops them. The state is // spent: Mapsemble has already dropped its side. Client::forget_account_caches(); delete_option( self::STATE_OPTION ); return true; } /** * Whether an admin could plausibly be mid-approval right now. */ public function has_pending_attempt(): bool { return '' !== $this->stored()['state']; } /** * The stored attempt, normalised; the empty shape once it has expired. * * The TTL is applied here, in the one reader, so that "is there an * attempt" and "which state is current" can never disagree: a stale * attempt is no attempt, to every caller. * * @return array{state: string, created: int} */ private function stored(): array { $none = array( 'state' => '', 'created' => 0, ); $pending = get_option( self::STATE_OPTION ); if ( ! is_array( $pending ) ) { return $none; } $created = (int) ( $pending['created'] ?? 0 ); if ( $created + self::TTL <= time() ) { return $none; } return array( 'state' => (string) ( $pending['state'] ?? '' ), 'created' => $created, ); } }