PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / google-calendar / Controller / calendar-auth-controller.php

calendar-auth-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.28, at core/module/google-calendar/Controller/calendar-auth-controller.php

667 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Calendar Auth Controller
4 *
5 * @package BookticsGoogleCalendar\Google\Controller
6 */
7
8 namespace Booktics\Module\Google_Calendar\Controller;
9
10 if ( ! defined( 'ABSPATH' ) ) exit;
11
12 use Booktics\Abstracts\Base_Rest_Controller;
13 use Exception;
14 use WP_REST_Request;
15 use WP_REST_Server;
16 use WP_Error;
17 use WP_HTTP_Response;
18
19 /**
20 * Class Calendar_Auth_Controller
21 */
22 class Calendar_Auth_Controller extends Base_Rest_Controller {
23
24 /**
25 * The namespace of this controller's route.
26 *
27 * @var string
28 */
29 protected $namespace = 'booktics/v1';
30
31 /**
32 * The base of this controller's route.
33 *
34 * @var string
35 */
36 protected $rest_base = 'settings/google';
37
38 /**
39 * User meta key for storing Google tokens
40 */
41 const BGC_USER_META_TOKEN = 'booktics_google_auth_token';
42
43 /**
44 * Google OAuth configuration.
45 *
46 * @var array
47 */
48 private $oauth_config;
49
50 /**
51 * Initialize the class and set up hooks.
52 */
53 public function __construct() {
54 $this->oauth_config = $this->get_oauth_config();
55 add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) );
56 add_action( 'admin_notices', array( $this, 'permalink_warning_notice' ) );
57
58 // In your addon's main file or a dedicated class
59 add_filter(
60 'booktics_team_member_data',
61 function ( $team_member, $team_member_id ) {
62 $team_member['google_calendar_connected'] = $this->team_member_is_connected( $team_member_id );
63
64 return $team_member;
65 },
66 10,
67 2
68 );
69 }
70
71 /**
72 * Warn the admin when the permalink structure breaks the OAuth callback.
73 *
74 * The Google callback lives at a pretty path ( booktics-integration/
75 * google-auth ), which WordPress only routes when permalinks are not set to
76 * Plain. On Plain that URL 404s and the connection silently fails, so a
77 * heads-up is shown — but only once Google is actually configured, to keep
78 * the notice off screens where it would be noise.
79 *
80 * @return void
81 */
82 public function permalink_warning_notice() {
83 if ( ! current_user_can( 'manage_options' ) ) {
84 return;
85 }
86
87 // Plain permalinks store an empty structure.
88 if ( get_option( 'permalink_structure' ) ) {
89 return;
90 }
91
92 if ( ! booktics_get_option( 'google_client_id' ) ) {
93 return;
94 }
95
96 printf(
97 '<div class="notice notice-warning"><p>%s</p></div>',
98 wp_kses(
99 sprintf(
100 /* translators: %s: link to the WordPress permalink settings screen. */
101 __( 'Booktics: Google Calendar sign-in needs a permalink structure other than <strong>Plain</strong>. Open <a href="%s">Settings &rarr; Permalinks</a> and pick <strong>Post name</strong>, or the Google connection will fail.', 'booktics' ),
102 esc_url( admin_url( 'options-permalink.php' ) )
103 ),
104 array(
105 'strong' => array(),
106 'a' => array( 'href' => array() ),
107 )
108 )
109 );
110 }
111
112 /**
113 * Get OAuth configuration for Google API
114 *
115 * @return array
116 */
117 private function get_oauth_config() {
118 return array(
119 'client_id' => booktics_get_option( 'google_client_id' ),
120 'client_secret' => booktics_get_option( 'google_secret_key' ),
121 'redirect_uri' => booktics_get_option( 'google_redirect_url' ),
122 'scope' => 'https://www.googleapis.com/auth/calendar',
123 'access_type' => 'offline',
124 'prompt' => 'consent',
125 );
126 }
127
128 /**
129 * Get valid access token for user, refreshing if necessary
130 *
131 * @param int $user_id
132 * @return string|false Access token or false on failure
133 */
134 private function get_valid_access_token( $user_id = null ) {
135 if ( null === $user_id ) {
136 $user_id = get_current_user_id();
137 }
138
139 $token = get_user_meta( $user_id, self::BGC_USER_META_TOKEN, true );
140 if ( ! $token ) {
141 return false;
142 }
143
144 // Handle both string (old format) and array (new format) tokens
145 if ( is_string( $token ) ) {
146 $token = json_decode( $token, true );
147 }
148
149 if ( ! is_array( $token ) || empty( $token['access_token'] ) ) {
150 return false;
151 }
152
153 // Check if token needs refreshing
154 if ( $this->is_token_expired( $token ) ) {
155 $refreshed_token = $this->refresh_access_token( $token, $user_id );
156 if ( ! $refreshed_token ) {
157 delete_user_meta( $user_id, self::BGC_USER_META_TOKEN );
158 return false;
159 }
160 return $refreshed_token['access_token'];
161 }
162
163 return $token['access_token'];
164 }
165
166 /**
167 * Check if token is expired
168 *
169 * @param array $token
170 * @return bool
171 */
172 private function is_token_expired( $token ) {
173 if ( empty( $token['expires_at'] ) ) {
174 return true;
175 }
176
177 $expires_at = is_numeric( $token['expires_at'] ) ? $token['expires_at'] : strtotime( $token['expires_at'] );
178 $buffer_time = 300; // 5 minutes buffer
179
180 return ( time() + $buffer_time ) >= $expires_at;
181 }
182
183 /**
184 * Refresh access token using refresh token
185 *
186 * @param array $token
187 * @param int $user_id
188 * @return array|false New token array or false on failure
189 */
190 private function refresh_access_token( $token, $user_id ) {
191 if ( empty( $token['refresh_token'] ) ) {
192 return false;
193 }
194
195 $response = wp_remote_post(
196 'https://oauth2.googleapis.com/token',
197 array(
198 'body' => array(
199 'client_id' => $this->oauth_config['client_id'],
200 'client_secret' => $this->oauth_config['client_secret'],
201 'refresh_token' => $token['refresh_token'],
202 'grant_type' => 'refresh_token',
203 ),
204 )
205 );
206
207 if ( is_wp_error( $response ) ) {
208 return false;
209 }
210
211 $body = wp_remote_retrieve_body( $response );
212 $new_token = json_decode( $body, true );
213
214 if ( isset( $new_token['error'] ) ) {
215 return false;
216 }
217
218 // Merge with existing token to preserve refresh_token
219 $refreshed_token = array_merge( $token, $new_token );
220
221 // Set expiry time
222 if ( isset( $refreshed_token['expires_in'] ) ) {
223 $refreshed_token['expires_at'] = time() + $refreshed_token['expires_in'];
224 }
225
226 // Save the refreshed token
227 update_user_meta( $user_id, self::BGC_USER_META_TOKEN, $refreshed_token );
228
229 return $refreshed_token;
230 }
231
232 /**
233 * Handle direct OAuth callback from Google.
234 */
235 public function handle_google_auth_callback() {
236 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
237 if ( ! isset( $_GET['code'] ) ) {
238 return;
239 }
240
241 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
242 if ( isset( $_GET['state'] ) ) {
243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
244 $decoded_state = base64_decode( sanitize_text_field( wp_unslash( $_GET['state'] ) ) );
245 $state_data = json_decode( $decoded_state, true );
246 $team_member_id = isset( $state_data['team_member_id'] ) ? $state_data['team_member_id'] : null;
247 }
248
249 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
250 $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
251
252 if ( ! isset( $team_member_id ) ) {
253 $team_member_id = get_current_user_id();
254 }
255
256 try {
257 $tokens = $this->exchange_code_for_tokens( $code );
258
259 if ( ! $tokens || isset( $tokens['error'] ) ) {
260 throw new Exception( $tokens['error_description'] ?? 'Authentication failed' );
261 }
262
263 // Set expiry time
264 if ( isset( $tokens['expires_in'] ) ) {
265 $tokens['expires_at'] = time() + $tokens['expires_in'];
266 }
267
268 update_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, $tokens );
269
270 if ( current_user_can( 'manage_options' ) ) {
271 booktics_update_option( 'google_calendar_connected', 1 );
272 }
273
274 $redirect_url = isset( $team_member_id ) && (int)$team_member_id != get_current_user_id() ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
275
276 wp_safe_redirect( $redirect_url );
277 exit;
278
279 } catch ( Exception $e ) {
280 $redirect_url = isset( $team_member_id ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
281
282 wp_safe_redirect( $redirect_url );
283 exit;
284 }
285 }
286
287 /**
288 * Exchange authorization code for access tokens
289 *
290 * @param string $code
291 * @return array|false
292 */
293 private function exchange_code_for_tokens( $code ) {
294 $response = wp_remote_post(
295 'https://oauth2.googleapis.com/token',
296 array(
297 'body' => array(
298 'client_id' => $this->oauth_config['client_id'],
299 'client_secret' => $this->oauth_config['client_secret'],
300 'code' => $code,
301 'grant_type' => 'authorization_code',
302 'redirect_uri' => $this->oauth_config['redirect_uri'],
303 ),
304 )
305 );
306
307 if ( is_wp_error( $response ) ) {
308 return false;
309 }
310
311 $body = wp_remote_retrieve_body( $response );
312 return json_decode( $body, true );
313 }
314
315 private function get_redirect_url_for_admin() {
316 return home_url( '/wp-admin/admin.php?page=booktics#/settings?tab=integration' );
317 }
318
319 private function get_redirect_url_for_team_member( $team_member_id ) {
320 return home_url( "/wp-admin/admin.php?page=booktics#/team-members/update/{$team_member_id}?tab=integration" );
321 }
322
323 /**
324 * Register the routes for the controller.
325 */
326 public function register_routes(): void {
327 // Get Google auth URL
328 register_rest_route(
329 $this->namespace,
330 $this->rest_base . '/connect',
331 array(
332 array(
333 'methods' => WP_REST_Server::READABLE,
334 'callback' => array( $this, 'get_auth_url' ),
335 'permission_callback' => array( $this, 'permission_check' ),
336 'args' => array(
337 'team_member_id' => array(
338 'description' => __( 'ID of the team member to connect', 'booktics' ),
339 'type' => 'integer',
340 'required' => false,
341 ),
342 ),
343 ),
344 )
345 );
346
347 // Revoke authentication
348 register_rest_route(
349 $this->namespace,
350 $this->rest_base . '/revoke',
351 array(
352 array(
353 'methods' => WP_REST_Server::DELETABLE,
354 'callback' => array( $this, 'revoke_auth' ),
355 'permission_callback' => array( $this, 'permission_check' ),
356 'args' => array(
357 'team_member_id' => array(
358 'description' => __( 'ID of the team member to disconnect', 'booktics' ),
359 'type' => 'integer',
360 'required' => false,
361 ),
362 ),
363 ),
364 )
365 );
366
367 // Google connect for admin
368 register_rest_route(
369 $this->namespace,
370 $this->rest_base . '/admin/connect',
371 array(
372 array(
373 'methods' => WP_REST_Server::CREATABLE,
374 'callback' => array( $this, 'get_admin_auth_url' ),
375 'permission_callback' => function () {
376 return current_user_can( 'manage_options' );
377 },
378 ),
379 )
380 );
381
382 // Check authentication status
383 register_rest_route(
384 $this->namespace,
385 $this->rest_base . '/check',
386 array(
387 array(
388 'methods' => WP_REST_Server::READABLE,
389 'callback' => array( $this, 'check_auth' ),
390 'permission_callback' => array( $this, 'permission_check' ),
391 'args' => array(
392 'team_member_id' => array(
393 'description' => __( 'ID of the team member to check', 'booktics' ),
394 'type' => 'integer',
395 'required' => false,
396 ),
397 ),
398 ),
399 )
400 );
401 }
402
403 /**
404 * Check if a given request has permission.
405 *
406 * @return bool|WP_Error
407 */
408 public function permission_check( $request ) {
409 if ( ! is_user_logged_in() ) {
410 return $this->error(
411 __( 'You must be logged in to access this endpoint.', 'booktics' ),
412 401,
413 'permission_error'
414 );
415 }
416
417 // Get team member ID from request
418 $team_member_id = $this->get_team_member_id( $request );
419
420 // If no team member ID specified, allow if user can manage options
421 if ( ! $team_member_id ) {
422 return current_user_can( 'manage_options' );
423 }
424
425 // If team member ID is specified, check if current user can edit that user
426 if ( get_current_user_id() !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) {
427 return $this->error(
428 __( 'You do not have permission to access this user\'s Google Calendar settings.', 'booktics' ),
429 403,
430 'permission_error'
431 );
432 }
433
434 return true;
435 }
436
437 public function get_admin_auth_url( $request ) {
438 $data = json_decode( $request->get_body(), true );
439 $google_client_id = $data['google_client_id'];
440 $google_client_secret = $data['google_secret_key'];
441 $block_booking_during_google_events = isset( $data['block_booking_during_google_events'] ) ? $data['block_booking_during_google_events'] : false;
442
443 // save credentials in options
444 booktics_update_option( 'google_client_id', $google_client_id );
445 booktics_update_option( 'google_secret_key', $google_client_secret );
446 booktics_update_option( 'block_booking_during_google_events', $block_booking_during_google_events );
447
448 // Update OAuth config
449 $this->oauth_config['client_id'] = $google_client_id;
450 $this->oauth_config['client_secret'] = $google_client_secret;
451
452 $auth_url = $this->create_auth_url();
453
454 return $this->response(
455 array( 'auth_url' => $auth_url ),
456 __( 'Google authentication URL generated successfully.', 'booktics' )
457 );
458 }
459
460 /**
461 * Get Google OAuth URL.
462 *
463 * @param WP_REST_Request $request
464 * @return WP_Error|WP_HTTP_Response
465 */
466 public function get_auth_url( $request ) {
467 try {
468 // Get any parameters you want to pass through
469 $state_params = array(
470 'team_member_id' => absint( $request->get_param( 'team_member_id' ) ),
471 );
472
473 // Convert to JSON and base64 encode to make it URL-safe
474 $state = base64_encode( wp_json_encode( $state_params ) );
475
476 $auth_url = $this->create_auth_url( $state );
477
478 if ( empty( $auth_url ) ) {
479 return $this->error(
480 __( 'Failed to generate Google authentication URL.', 'booktics' ),
481 500,
482 'auth_url_error'
483 );
484 }
485
486 return $this->response(
487 array( 'auth_url' => $auth_url ),
488 __( 'Google authentication URL generated successfully.', 'booktics' )
489 );
490
491 } catch ( Exception $e ) {
492 return $this->error(
493 /* translators: %s: Error message */
494 sprintf( __( 'Failed to generate Google auth URL: %s', 'booktics' ), $e->getMessage() ),
495 500,
496 'auth_error'
497 );
498 }
499 }
500
501 /**
502 * Create OAuth authorization URL
503 *
504 * @param string $state Optional state parameter
505 * @return string
506 */
507 private function create_auth_url( $state = '' ) {
508 $params = array(
509 'client_id' => $this->oauth_config['client_id'],
510 'redirect_uri' => $this->oauth_config['redirect_uri'],
511 'scope' => $this->oauth_config['scope'],
512 'response_type' => 'code',
513 'access_type' => $this->oauth_config['access_type'],
514 'prompt' => $this->oauth_config['prompt'],
515 );
516
517 if ( ! empty( $state ) ) {
518 $params['state'] = $state;
519 }
520
521 return 'https://accounts.google.com/o/oauth2/auth?' . http_build_query( $params );
522 }
523
524 /**
525 * Revoke Google authentication.
526 *
527 * @param WP_REST_Request $request
528 * @return WP_Error|WP_HTTP_Response
529 */
530 public function revoke_auth( $request ) {
531 try {
532 $team_member_id = $this->get_team_member_id( $request );
533
534 if ( current_user_can( 'manage_options' ) && (int)$team_member_id == get_current_user_id() ) {
535 booktics_update_option( 'google_calendar_connected', 0 );
536 }
537
538 // Get the token before revoking
539 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
540
541 if ( empty( $token ) ) {
542 return $this->response(
543 array( 'revoked' => true ),
544 __( 'Google account was not connected.', 'booktics' )
545 );
546 }
547
548 // Handle both string (old format) and array (new format) tokens
549 if ( is_string( $token ) ) {
550 $token = json_decode( $token, true );
551 }
552
553 // Revoke the token
554 if ( isset( $token['access_token'] ) ) {
555 $this->revoke_token( $token['access_token'] );
556 }
557
558 // Delete the token
559 delete_user_meta( $team_member_id, self::BGC_USER_META_TOKEN );
560
561 return $this->response(
562 array( 'revoked' => true ),
563 __( 'Google authentication revoked successfully.', 'booktics' )
564 );
565
566 } catch ( Exception $e ) {
567 return $this->error(
568 /* translators: %s: Error message */
569 sprintf( __( 'Failed to revoke Google authentication: %s', 'booktics' ), $e->getMessage() ),
570 500,
571 'revoke_error'
572 );
573 }
574 }
575
576 /**
577 * Revoke access token at Google
578 *
579 * @param string $access_token
580 * @return bool
581 */
582 private function revoke_token( $access_token ) {
583 $response = wp_remote_post(
584 'https://oauth2.googleapis.com/revoke',
585 array(
586 'body' => array(
587 'token' => $access_token,
588 ),
589 )
590 );
591
592 return ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200;
593 }
594
595 /**
596 * Check authentication status.
597 *
598 * @param WP_REST_Request $request
599 * @return WP_Error|WP_HTTP_Response
600 */
601 public function check_auth( $request ) {
602 try {
603 $team_member_id = $this->get_team_member_id( $request );
604 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
605 $is_authenticated = false;
606
607 if ( $token ) {
608 // Handle both string (old format) and array (new format) tokens
609 if ( is_string( $token ) ) {
610 $token = json_decode( $token, true );
611 }
612
613 if ( is_array( $token ) && isset( $token['access_token'] ) ) {
614 $is_authenticated = ! $this->is_token_expired( $token );
615
616 // If token is expired but we have a refresh token, try to refresh
617 if ( ! $is_authenticated && isset( $token['refresh_token'] ) ) {
618 $new_token = $this->refresh_access_token( $token, $team_member_id );
619 if ( $new_token ) {
620 $is_authenticated = true;
621 }
622 }
623 }
624 }
625
626 return $this->response(
627 array( 'authenticated' => $is_authenticated ),
628 $is_authenticated
629 ? __( 'Google account is connected.', 'booktics' )
630 : __( 'Google account is not connected.', 'booktics' )
631 );
632
633 } catch ( Exception $e ) {
634 return $this->error(
635 /* translators: %s: Error message */
636 sprintf( __( 'Failed to check authentication status: %s', 'booktics' ), $e->getMessage() ),
637 500,
638 'auth_check_error'
639 );
640 }
641 }
642
643 private function get_team_member_id( $request ) {
644 $data = $request->get_method() === 'GET'
645 ? $request->get_query_params()
646 : json_decode( $request->get_body(), true );
647
648 if ( isset( $data['team_member_id'] ) ) {
649 return absint( $data['team_member_id'] );
650 }
651
652 return get_current_user_id();
653 }
654
655 /**
656 * Check if team member is connected
657 *
658 * @param int $team_member_id team member id or user id
659 * @return int 1 if connected, 0 if not
660 */
661 private function team_member_is_connected( $team_member_id ) {
662 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
663
664 return empty( $token ) ? 0 : 1;
665 }
666 }
667