GoogleClient.php
414 lines
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * Exception with a description field. |
| 5 | **/ |
| 6 | class EmbedPress_GoogleClient_RequestException extends Exception { |
| 7 | |
| 8 | private $description; |
| 9 | |
| 10 | function __construct($message, $code = 0, $description = '') { |
| 11 | parent::__construct($message, $code ); |
| 12 | $this->description = $description; |
| 13 | |
| 14 | } |
| 15 | public function getDescription() { |
| 16 | return $this->description; |
| 17 | } |
| 18 | |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * Class that can do an HTTP request. |
| 23 | **/ |
| 24 | class EmbedPress_GoogleClient_Request { |
| 25 | |
| 26 | // See for example: https://developers.google.com/drive/v3/web/handle-errors |
| 27 | private static $HTTP_CODES = [ |
| 28 | 400 => 'Bad request. User error.', |
| 29 | 401 => 'Invalid Credentials. Invalid authorization header. The access token you\'re using is either expired or invalid.', |
| 30 | 403 => 'Rate Limit Exceeded.', |
| 31 | 404 => 'Not found. The specified resource was not found.', |
| 32 | 500 => 'Backend error.' |
| 33 | ]; |
| 34 | |
| 35 | /** |
| 36 | * Does a HTTP request |
| 37 | * |
| 38 | * @param string $url URL to request |
| 39 | * @param array $params Parameters to add to the URL or request body |
| 40 | * @param string $method HTTP method to use |
| 41 | * @param array $headers Headers for this request |
| 42 | * |
| 43 | * @return array JSON result decoded as an array |
| 44 | * |
| 45 | * @throws EmbedPress_GoogleClient_RequestException |
| 46 | **/ |
| 47 | public static function doRequest($url, $params = [], $method = 'GET', $headers = []) { |
| 48 | |
| 49 | $args = []; |
| 50 | if (!empty($headers)) { |
| 51 | $args['headers'] = $headers; |
| 52 | } |
| 53 | |
| 54 | $args['timeout'] = 10; // default is 5 seconds. |
| 55 | |
| 56 | switch ($method) { |
| 57 | case 'GET': |
| 58 | if (!empty($params)) { |
| 59 | $url .= '?' . http_build_query($params); // urlencoded is done for you |
| 60 | //$url = add_query_arg($params, $url); // wp variant, but I think you still have to urlencoded it |
| 61 | } |
| 62 | $result = wp_remote_get($url, $args); |
| 63 | break; |
| 64 | case 'POST': |
| 65 | if (!empty($params)) { |
| 66 | $args['body'] = $params; // TODO: Do we have to urlencoded these manually? |
| 67 | } |
| 68 | $result = wp_remote_post($url, $args); |
| 69 | break; |
| 70 | default: |
| 71 | throw new EmbedPress_GoogleClient_RequestException('Unknown request method.'); |
| 72 | } |
| 73 | |
| 74 | if (empty($result)) { |
| 75 | throw new EmbedPress_GoogleClient_RequestException('Request failed.'); |
| 76 | } |
| 77 | |
| 78 | if (is_wp_error($result)) { |
| 79 | throw new EmbedPress_GoogleClient_RequestException($result->get_error_message()); |
| 80 | } |
| 81 | |
| 82 | $decodedResult = json_decode(wp_remote_retrieve_body($result), true); |
| 83 | if (is_null($decodedResult)) { |
| 84 | throw new EmbedPress_GoogleClient_RequestException('Response is invalid JSON.', 0, $result); |
| 85 | } |
| 86 | if (!empty($decodedResult['error'])) { |
| 87 | $exCode = 0; |
| 88 | $exMessage = 'Something went wrong.'; |
| 89 | $exDescription = ''; |
| 90 | if (is_array($decodedResult['error'])) { |
| 91 | if (!empty($decodedResult['error']['message'])) { |
| 92 | $exMessage = $decodedResult['error']['message']; |
| 93 | } |
| 94 | if (!empty($decodedResult['error']['code']) && preg_match("/^\d+$/", $decodedResult['error']['code'])) { |
| 95 | $exCode = $decodedResult['error']['code']; |
| 96 | } |
| 97 | } else { |
| 98 | if (!empty($decodedResult['error_description'])) { |
| 99 | $exMessage = $decodedResult['error_description']; |
| 100 | } |
| 101 | if (!empty($decodedResult['code']) && preg_match("/^\d+$/", $decodedResult['code'])) { |
| 102 | $exCode = $decodedResult['code']; |
| 103 | } |
| 104 | } |
| 105 | if (!empty(self::$HTTP_CODES[$exCode])) { |
| 106 | $exDescription = self::$HTTP_CODES[$exCode]; |
| 107 | } |
| 108 | throw new EmbedPress_GoogleClient_RequestException($exMessage, $exCode, $exDescription); |
| 109 | } |
| 110 | return $decodedResult; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * The Google OAuth client class. |
| 116 | **/ |
| 117 | class EmbedPress_GoogleClient { |
| 118 | |
| 119 | /** |
| 120 | * Array of client_secret that can be downloaded from the Google api console at: |
| 121 | * https://console.developers.google.com |
| 122 | **/ |
| 123 | private $clientInfo; |
| 124 | |
| 125 | /** |
| 126 | * @var array The access token info as an array that is returned by the Google API. |
| 127 | **/ |
| 128 | private $accessTokenInfo; |
| 129 | |
| 130 | /** |
| 131 | * @var string The refreshed |
| 132 | **/ |
| 133 | private $refreshToken; |
| 134 | |
| 135 | private $scope; |
| 136 | private $redirectUri; |
| 137 | |
| 138 | const GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth'; |
| 139 | const GOOGLE_REFRESH_URI = 'https://www.googleapis.com/oauth2/v4/token'; |
| 140 | const GOOGLE_CODE_URI = 'https://www.googleapis.com/oauth2/v4/token'; |
| 141 | const GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke'; |
| 142 | |
| 143 | // Gets called whenever we receive a new access and optionally refresh token. |
| 144 | // So mostly after authorize phase (with refresh token) or after refresh token (only access token). |
| 145 | private $tokenCallback; |
| 146 | /** |
| 147 | * @var null|mixed |
| 148 | */ |
| 149 | private $getAccessTokenInfo; |
| 150 | |
| 151 | function __construct($clientInfo, $accessTokenInfo = null, $refreshToken = null, $tokenCallback = null) { |
| 152 | $this->clientInfo = $clientInfo; |
| 153 | $this->getAccessTokenInfo = $accessTokenInfo; |
| 154 | $this->refreshToken = $refreshToken; |
| 155 | $this->tokenCallback = $tokenCallback; |
| 156 | $this->scope = null; |
| 157 | $this->redirectUri = null; |
| 158 | } |
| 159 | |
| 160 | public function setAccessTokenInfo($accessTokenInfo) { |
| 161 | $this->accessTokenInfo = $accessTokenInfo; |
| 162 | } |
| 163 | |
| 164 | public function getAccessTokenInfo() { |
| 165 | return $this->accessTokenInfo; |
| 166 | } |
| 167 | |
| 168 | // Helper |
| 169 | public function getAccessToken() { |
| 170 | return $this->accessTokenInfo['access_token']; |
| 171 | } |
| 172 | |
| 173 | public function setRefreshToken($refreshToken) { |
| 174 | $this->refreshToken = $refreshToken; |
| 175 | } |
| 176 | |
| 177 | public function getRefreshToken() { |
| 178 | return $this->refreshToken; |
| 179 | } |
| 180 | |
| 181 | public function setTokenCallback($tokenCallback) { |
| 182 | $this->tokenCallback = $tokenCallback; |
| 183 | } |
| 184 | |
| 185 | public function setScope($scope) { |
| 186 | $this->scope = $scope; |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * @throws Exception |
| 191 | */ |
| 192 | public function setRedirectUri($redirectUri) { |
| 193 | if (!in_array($redirectUri, $this->clientInfo['web']['redirect_uris'])) { |
| 194 | throw new Exception('Redirect Uri does not exist in client info. Add it first to the project and download the JSON again.'); |
| 195 | } |
| 196 | $this->redirectUri = $redirectUri; |
| 197 | } |
| 198 | |
| 199 | public function isAccessTokenExpired() { |
| 200 | // Add 30 seconds, so we refresh them on time. |
| 201 | return $this->accessTokenInfo['expire_time'] + 30 < time(); |
| 202 | } |
| 203 | |
| 204 | private function updateTokens($response) { |
| 205 | // Compute when access token expires and add it to the info array. |
| 206 | $response['expire_time'] = time() + $response['expires_in']; |
| 207 | // Only after authorize we get a refresh token, so make sure to save it! |
| 208 | // If you loose it, you have to revoke permission and authorize again. |
| 209 | $this->setAccessTokenInfo($response); |
| 210 | $refreshToken = !empty($response['refresh_token']) ? $response['refresh_token'] : null; |
| 211 | if (!empty($refreshToken)) { |
| 212 | $this->setRefreshToken($refreshToken); |
| 213 | } |
| 214 | call_user_func($this->tokenCallback, $response, $refreshToken); |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * @throws EmbedPress_GoogleClient_RequestException |
| 219 | * @throws Exception |
| 220 | */ |
| 221 | public function handleCodeRedirect($state = '') { |
| 222 | if (!empty($_GET['error'])) { |
| 223 | throw new Exception($_GET['error']); |
| 224 | } |
| 225 | if (empty($_GET['code'])) { |
| 226 | throw new Exception('Code missing'); |
| 227 | } |
| 228 | $getState = !empty($_GET['state']) ? $_GET['state'] : ''; |
| 229 | if ($getState !== $state) { |
| 230 | throw new Exception("State mismatch."); |
| 231 | } |
| 232 | |
| 233 | $result = EmbedPress_GoogleClient_Request::doRequest(self::GOOGLE_CODE_URI, [ |
| 234 | 'code' => $_GET['code'], |
| 235 | 'client_id' => $this->clientInfo['web']['client_id'], |
| 236 | 'client_secret' => $this->clientInfo['web']['client_secret'], |
| 237 | 'redirect_uri' => $this->redirectUri, |
| 238 | 'grant_type' => 'authorization_code' |
| 239 | ], 'POST', [ |
| 240 | 'Content-Type' => 'application/x-www-form-urlencoded' |
| 241 | ]); |
| 242 | |
| 243 | $this->updateTokens($result); |
| 244 | } |
| 245 | |
| 246 | public function authorize($state = '') { |
| 247 | $params = [ |
| 248 | 'client_id' => $this->clientInfo['web']['client_id'], |
| 249 | 'scope' => $this->scope, |
| 250 | 'access_type' => 'offline', |
| 251 | 'include_granted_scopes' => 'true', |
| 252 | 'state' => $state, |
| 253 | 'redirect_uri' => $this->redirectUri, |
| 254 | 'response_type' => 'code' |
| 255 | ]; |
| 256 | $url = self::GOOGLE_AUTH_URI . '?' . http_build_query($params); |
| 257 | header('Location: ' . $url); |
| 258 | exit; |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * @throws EmbedPress_GoogleClient_RequestException |
| 263 | */ |
| 264 | public function refreshAccessToken() { |
| 265 | $result = EmbedPress_GoogleClient_Request::doRequest( |
| 266 | self::GOOGLE_REFRESH_URI, |
| 267 | [ |
| 268 | 'client_id' => $this->clientInfo['web']['client_id'], |
| 269 | 'client_secret' => $this->clientInfo['web']['client_secret'], |
| 270 | 'refresh_token' => $this->refreshToken, |
| 271 | 'grant_type' => 'refresh_token' |
| 272 | ], |
| 273 | 'POST', |
| 274 | [ |
| 275 | 'Content-Type' => 'application/x-www-form-urlencoded' |
| 276 | ] |
| 277 | ); |
| 278 | $this->updateTokens($result); |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * @throws EmbedPress_GoogleClient_RequestException |
| 283 | * @throws Exception |
| 284 | */ |
| 285 | public function revoke() { |
| 286 | // Can be done with access and refresh token, |
| 287 | // but as access tokens expire more frequent, first take refresh token. |
| 288 | // It can take some time before the revoke is processed. |
| 289 | $token = $this->getRefreshToken(); |
| 290 | if (empty($token)) { |
| 291 | $token = $this->getAccessToken(); |
| 292 | } |
| 293 | if (empty($token)) { |
| 294 | throw new Exception('No access and refresh token.'); |
| 295 | } |
| 296 | EmbedPress_GoogleClient_Request::doRequest( |
| 297 | self::GOOGLE_REVOKE_URI, ['token' => $token]); |
| 298 | // TODO: do we have 200 status code??? |
| 299 | |
| 300 | } |
| 301 | |
| 302 | } |
| 303 | |
| 304 | // https://developers.google.com/google-apps/calendar/v3/reference/events/list |
| 305 | /** |
| 306 | * Class that can communicate with the Google Calendar API. |
| 307 | **/ |
| 308 | class EmbedPress_GoogleCalendarClient { |
| 309 | |
| 310 | const GOOGLE_CALENDAR_EVENTS_URI = 'https://www.googleapis.com/calendar/v3/calendars/$calendarId/events'; |
| 311 | const GOOGLE_CALENDARLIST_URI = 'https://www.googleapis.com/calendar/v3/users/me/calendarList'; |
| 312 | const GOOGLE_COLORLIST_URI = 'https://www.googleapis.com/calendar/v3/colors'; |
| 313 | |
| 314 | private $googleClient; |
| 315 | |
| 316 | function __construct($client) { |
| 317 | $this->googleClient = $client; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * @throws EmbedPress_GoogleClient_RequestException |
| 322 | */ |
| 323 | public function getEvents($calendarId, $params) { |
| 324 | $url = str_replace('$calendarId', urlencode($calendarId), self::GOOGLE_CALENDAR_EVENTS_URI); |
| 325 | // https://developers.google.com/google-apps/calendar/performance#partial-response |
| 326 | $params['fields'] = "items(summary,description,start,end,htmlLink,creator,location,attendees,attachments,colorId)"; |
| 327 | $result = EmbedPress_GoogleClient_Request::doRequest( |
| 328 | $url, |
| 329 | $params, |
| 330 | 'GET', |
| 331 | [ |
| 332 | 'Authorization' => 'Bearer ' . $this->googleClient->getAccessToken(), |
| 333 | ] |
| 334 | ); |
| 335 | |
| 336 | return !empty($result['items']) ? $result['items'] : []; |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * @throws EmbedPress_GoogleClient_RequestException |
| 341 | */ |
| 342 | public function getEventsPublic($calendarId, $params, $apiKey, $referer) { |
| 343 | $url = str_replace('$calendarId', urlencode($calendarId), self::GOOGLE_CALENDAR_EVENTS_URI); |
| 344 | // https://developers.google.com/google-apps/calendar/performance#partial-response |
| 345 | $params['fields'] = "items(summary,description,start,end,htmlLink,creator,location,attendees,attachments,colorId)"; |
| 346 | $params['key'] = $apiKey; |
| 347 | $result = EmbedPress_GoogleClient_Request::doRequest( |
| 348 | $url, |
| 349 | $params, |
| 350 | 'GET', |
| 351 | [ |
| 352 | 'Referer' => $referer |
| 353 | ] |
| 354 | ); |
| 355 | |
| 356 | return !empty($result['items']) ? $result['items'] : []; |
| 357 | } |
| 358 | |
| 359 | /** |
| 360 | * @throws EmbedPress_GoogleClient_RequestException |
| 361 | */ |
| 362 | public function getPrimaryEvents($params) { |
| 363 | return $this->getEvents('primary', $params); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * @return string JSON with items as key each item is calendarListEntry |
| 368 | * calendarListEntry: |
| 369 | * 'id' => string 'ehqelgh6hq4juqhjd79g4b5qkk@group.calendar.google.com' ==> use this for event list |
| 370 | * 'summary' => string 'Vacationers' |
| 371 | * 'description' => string 'Agenda voor de vakantierooster' |
| 372 | * 'backgroundColor' => string '#cd74e6' |
| 373 | * 'foregroundColor' => string '#000000' |
| 374 | * 'selected' => boolean true ==> alleen aanwezig als geselecteerd! Geeft aan of de gebruiker deze calendat in de Google ui aan heeft gezet |
| 375 | * 'primary' => boolean true ==> alleen aanwezig bij primary calendar! |
| 376 | * 'accessRole' => we can get events only for 'owner' items, so we only query these. |
| 377 | * @throws EmbedPress_GoogleClient_RequestException |
| 378 | */ |
| 379 | public function getCalendarList() { |
| 380 | $result = EmbedPress_GoogleClient_Request::doRequest( |
| 381 | self::GOOGLE_CALENDARLIST_URI, |
| 382 | [ |
| 383 | 'minAccessRole' => 'reader' // if 'owner', then you don't see calendars like national holidays. |
| 384 | ], |
| 385 | 'GET', |
| 386 | [ |
| 387 | 'Authorization' => 'Bearer ' . $this->googleClient->getAccessToken() |
| 388 | ] |
| 389 | ); |
| 390 | |
| 391 | return !empty($result['items']) ? $result['items'] : []; |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * @throws EmbedPress_GoogleClient_RequestException |
| 396 | */ |
| 397 | public function getColorList() { |
| 398 | $result = EmbedPress_GoogleClient_Request::doRequest( |
| 399 | self::GOOGLE_COLORLIST_URI, |
| 400 | null, |
| 401 | 'GET', |
| 402 | [ |
| 403 | 'Authorization' => 'Bearer ' . $this->googleClient->getAccessToken() |
| 404 | ] |
| 405 | ); |
| 406 | $calendar = !empty($result['calendar']) ? $result['calendar'] : []; |
| 407 | $event = !empty($result['event']) ? $result['event'] : []; |
| 408 | return [ |
| 409 | 'calendar' => $calendar, |
| 410 | 'event' => $event |
| 411 | ]; |
| 412 | } |
| 413 | |
| 414 | } |