|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * googlecal: connect a Google account so the Calendar tab reads and writes (create, |
| 5 | + * edit, delete) Google Calendar over CalDAV with OAuth 2. |
| 6 | + * |
| 7 | + * Settings > Preferences > Google Calendar: Connect / Disconnect, and a switch to keep |
| 8 | + * using the default calendar (Forward Email or local) while connected. When connected |
| 9 | + * and on, the calendar driver is caldav against apidata.googleusercontent.com and |
| 10 | + * libkolab's DAV client sends `Authorization: Bearer <access token>` to that host only |
| 11 | + * (patched in the Dockerfile). Tasks stay on the default backend: Google CalDAV has no |
| 12 | + * VTODO. |
| 13 | + * |
| 14 | + * The refresh token is stored in the user's prefs, encrypted with des_key. Access |
| 15 | + * tokens live in the session and are refreshed a minute before they expire. |
| 16 | + * |
| 17 | + * Config (zz-profullstack.inc.php): googlecal_client_id, googlecal_client_secret, |
| 18 | + * googlecal_redirect_uri (must match the OAuth client exactly). |
| 19 | + * |
| 20 | + * Source: profullstack/cli-tools dev2/webmail/googlecal. |
| 21 | + */ |
| 22 | +class googlecal extends rcube_plugin |
| 23 | +{ |
| 24 | + public $task = 'mail|settings|calendar|tasks|addressbook|utils'; |
| 25 | + |
| 26 | + const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; |
| 27 | + const TOKEN_URL = 'https://oauth2.googleapis.com/token'; |
| 28 | + const REVOKE_URL = 'https://oauth2.googleapis.com/revoke'; |
| 29 | + const SCOPE = 'openid email https://www.googleapis.com/auth/calendar'; |
| 30 | + const DAV = 'https://apidata.googleusercontent.com/caldav/v2'; |
| 31 | + const DAV_HOST = 'apidata.googleusercontent.com'; |
| 32 | + |
| 33 | + private $rc; |
| 34 | + |
| 35 | + public function init() |
| 36 | + { |
| 37 | + $this->rc = rcmail::get_instance(); |
| 38 | + $this->add_hook('startup', [$this, 'startup']); |
| 39 | + $this->register_action('plugin.googlecal-connect', [$this, 'connect']); |
| 40 | + $this->register_action('plugin.googlecal-callback', [$this, 'callback']); |
| 41 | + $this->register_action('plugin.googlecal-disconnect', [$this, 'disconnect']); |
| 42 | + $this->add_hook('preferences_sections_list', [$this, 'sections']); |
| 43 | + $this->add_hook('preferences_list', [$this, 'prefs']); |
| 44 | + $this->add_hook('preferences_save', [$this, 'save']); |
| 45 | + } |
| 46 | + |
| 47 | + /** Runs after anyhost's startup hook (plugin order), before calendar's. */ |
| 48 | + public function startup($args) |
| 49 | + { |
| 50 | + $st = $this->state(); |
| 51 | + if (!$st || !empty($st['off']) || !$this->configured()) { |
| 52 | + return $args; |
| 53 | + } |
| 54 | + $token = $this->access_token($st); |
| 55 | + if (!$token) { |
| 56 | + return $args; |
| 57 | + } |
| 58 | + $config = $this->rc->config; |
| 59 | + $config->set('calendar_driver', 'caldav'); |
| 60 | + $config->set('calendar_caldav_server', self::DAV); |
| 61 | + $tokens = (array) $config->get('dav_oauth_tokens', []); |
| 62 | + $tokens[self::DAV_HOST] = $token; |
| 63 | + $config->set('dav_oauth_tokens', $tokens); |
| 64 | + return $args; |
| 65 | + } |
| 66 | + |
| 67 | + public function connect() |
| 68 | + { |
| 69 | + if (!$this->configured()) { |
| 70 | + $this->rc->output->show_message('Google Calendar is not set up on this server yet.', 'error'); |
| 71 | + $this->rc->output->redirect(['_task' => 'settings', '_action' => 'edit-prefs', '_section' => 'googlecal']); |
| 72 | + return; |
| 73 | + } |
| 74 | + $_SESSION['googlecal_state'] = $state = bin2hex(random_bytes(16)); |
| 75 | + $url = self::AUTH_URL . '?' . http_build_query([ |
| 76 | + 'client_id' => $this->rc->config->get('googlecal_client_id'), |
| 77 | + 'redirect_uri' => $this->redirect_uri(), |
| 78 | + 'response_type' => 'code', |
| 79 | + 'scope' => self::SCOPE, |
| 80 | + 'access_type' => 'offline', |
| 81 | + 'prompt' => 'consent', |
| 82 | + 'include_granted_scopes' => 'true', |
| 83 | + 'state' => $state, |
| 84 | + ]); |
| 85 | + header('Location: ' . $url); |
| 86 | + exit; |
| 87 | + } |
| 88 | + |
| 89 | + public function callback() |
| 90 | + { |
| 91 | + $back = ['_task' => 'settings', '_action' => 'preferences', '_section' => 'googlecal']; |
| 92 | + $state = rcube_utils::get_input_string('state', rcube_utils::INPUT_GET); |
| 93 | + $expected = $_SESSION['googlecal_state'] ?? ''; |
| 94 | + unset($_SESSION['googlecal_state']); |
| 95 | + if ($expected === '' || !hash_equals($expected, (string) $state)) { |
| 96 | + $this->rc->output->show_message('Google sign-in expired or was tampered with; try Connect again.', 'error'); |
| 97 | + $this->rc->output->redirect($back); |
| 98 | + return; |
| 99 | + } |
| 100 | + $code = rcube_utils::get_input_string('code', rcube_utils::INPUT_GET); |
| 101 | + if (!$code) { |
| 102 | + $err = rcube_utils::get_input_string('error', rcube_utils::INPUT_GET); |
| 103 | + $this->rc->output->show_message('Google did not connect' . ($err ? " ($err)" : '') . '.', 'warning'); |
| 104 | + $this->rc->output->redirect($back); |
| 105 | + return; |
| 106 | + } |
| 107 | + $r = $this->post(self::TOKEN_URL, [ |
| 108 | + 'code' => $code, |
| 109 | + 'client_id' => $this->rc->config->get('googlecal_client_id'), |
| 110 | + 'client_secret' => $this->rc->config->get('googlecal_client_secret'), |
| 111 | + 'redirect_uri' => $this->redirect_uri(), |
| 112 | + 'grant_type' => 'authorization_code', |
| 113 | + ]); |
| 114 | + if (empty($r['refresh_token']) || empty($r['access_token'])) { |
| 115 | + rcube::raise_error(['code' => 500, 'message' => 'googlecal: token exchange failed: ' . ($r['error'] ?? 'no refresh_token')], true, false); |
| 116 | + $this->rc->output->show_message('Google sign-in failed; try Connect again.', 'error'); |
| 117 | + $this->rc->output->redirect($back); |
| 118 | + return; |
| 119 | + } |
| 120 | + $email = ''; |
| 121 | + if (!empty($r['id_token']) && count($parts = explode('.', $r['id_token'])) === 3) { |
| 122 | + $claims = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); |
| 123 | + $email = (string) ($claims['email'] ?? ''); |
| 124 | + } |
| 125 | + $this->save_state(['refresh' => $r['refresh_token'], 'email' => $email, 'off' => false]); |
| 126 | + $_SESSION['googlecal_at'] = $r['access_token']; |
| 127 | + $_SESSION['googlecal_exp'] = time() + (int) ($r['expires_in'] ?? 3600); |
| 128 | + $this->rc->output->show_message('Google Calendar connected' . ($email ? " ($email)" : '') . '.', 'confirmation'); |
| 129 | + $this->rc->output->redirect(['_task' => 'calendar']); |
| 130 | + } |
| 131 | + |
| 132 | + public function disconnect() |
| 133 | + { |
| 134 | + if (!$this->rc->check_request(rcube_utils::INPUT_GET)) { |
| 135 | + $this->rc->output->show_message('Request check failed; reload Settings and try again.', 'error'); |
| 136 | + } elseif ($st = $this->state()) { |
| 137 | + $this->post(self::REVOKE_URL, ['token' => $st['refresh']]); |
| 138 | + $this->save_state(null); |
| 139 | + unset($_SESSION['googlecal_at'], $_SESSION['googlecal_exp']); |
| 140 | + $this->rc->output->show_message('Google Calendar disconnected.', 'confirmation'); |
| 141 | + } |
| 142 | + $this->rc->output->redirect(['_task' => 'settings', '_action' => 'preferences', '_section' => 'googlecal']); |
| 143 | + } |
| 144 | + |
| 145 | + public function sections($args) |
| 146 | + { |
| 147 | + $args['list']['googlecal'] = ['id' => 'googlecal', 'section' => 'Google Calendar']; |
| 148 | + return $args; |
| 149 | + } |
| 150 | + |
| 151 | + public function prefs($args) |
| 152 | + { |
| 153 | + if ($args['section'] !== 'googlecal') { |
| 154 | + return $args; |
| 155 | + } |
| 156 | + $st = $this->state(); |
| 157 | + $blocks = ['main' => ['name' => 'Google Calendar', 'options' => []]]; |
| 158 | + if (!$this->configured()) { |
| 159 | + $blocks['main']['options']['status'] = ['title' => 'Status', 'content' => 'Not available yet: the server has no Google OAuth client configured.']; |
| 160 | + } elseif (!$st) { |
| 161 | + $link = $this->rc->url(['_task' => 'settings', '_action' => 'plugin.googlecal-connect']); |
| 162 | + $blocks['main']['options']['status'] = ['title' => 'Status', 'content' => 'Not connected']; |
| 163 | + $blocks['main']['options']['connect'] = ['title' => '', 'content' => |
| 164 | + html::a(['href' => $link, 'class' => 'button mainaction', 'target' => '_top'], 'Connect Google Calendar') |
| 165 | + . html::p('hint', 'Your Calendar tab will show and edit your Google calendars. Tasks stay where they are.')]; |
| 166 | + } else { |
| 167 | + $link = $this->rc->url(['_task' => 'settings', '_action' => 'plugin.googlecal-disconnect', '_token' => $this->rc->get_request_token()]); |
| 168 | + $use = new html_checkbox(['name' => '_googlecal_use', 'id' => 'googlecal_use', 'value' => 1]); |
| 169 | + $blocks['main']['options']['status'] = ['title' => 'Status', 'content' => rcube::Q('Connected' . (!empty($st['email']) ? ' as ' . $st['email'] : ''))]; |
| 170 | + $blocks['main']['options']['use'] = ['title' => html::label('googlecal_use', 'Use Google as my calendar'), |
| 171 | + 'content' => $use->show(empty($st['off']) ? 1 : 0) . html::p('hint', 'Untick to go back to your default calendar without disconnecting.')]; |
| 172 | + $blocks['main']['options']['disconnect'] = ['title' => '', 'content' => |
| 173 | + html::a(['href' => $link, 'class' => 'button', 'target' => '_top'], 'Disconnect Google')]; |
| 174 | + } |
| 175 | + $args['blocks'] = $blocks; |
| 176 | + return $args; |
| 177 | + } |
| 178 | + |
| 179 | + public function save($args) |
| 180 | + { |
| 181 | + if ($args['section'] === 'googlecal' && ($st = $this->state())) { |
| 182 | + $st['off'] = empty($_POST['_googlecal_use']); |
| 183 | + $this->save_state($st); |
| 184 | + } |
| 185 | + return $args; |
| 186 | + } |
| 187 | + |
| 188 | + private function configured() |
| 189 | + { |
| 190 | + return $this->rc->config->get('googlecal_client_id') && $this->rc->config->get('googlecal_client_secret'); |
| 191 | + } |
| 192 | + |
| 193 | + private function redirect_uri() |
| 194 | + { |
| 195 | + return $this->rc->config->get('googlecal_redirect_uri', |
| 196 | + 'https://webmail.profullstack.com/?_task=settings&_action=plugin.googlecal-callback'); |
| 197 | + } |
| 198 | + |
| 199 | + private function state() |
| 200 | + { |
| 201 | + $raw = $this->rc->config->get('googlecal_state'); |
| 202 | + if (!$raw) { |
| 203 | + return null; |
| 204 | + } |
| 205 | + $st = json_decode((string) $this->rc->decrypt($raw), true); |
| 206 | + return is_array($st) && !empty($st['refresh']) ? $st : null; |
| 207 | + } |
| 208 | + |
| 209 | + private function save_state($st) |
| 210 | + { |
| 211 | + $value = $st ? $this->rc->encrypt(json_encode($st)) : null; |
| 212 | + $this->rc->user->save_prefs(['googlecal_state' => $value]); |
| 213 | + $this->rc->config->set('googlecal_state', $value); |
| 214 | + } |
| 215 | + |
| 216 | + private function access_token($st) |
| 217 | + { |
| 218 | + if (!empty($_SESSION['googlecal_at']) && ($_SESSION['googlecal_exp'] ?? 0) > time() + 60) { |
| 219 | + return $_SESSION['googlecal_at']; |
| 220 | + } |
| 221 | + $r = $this->post(self::TOKEN_URL, [ |
| 222 | + 'client_id' => $this->rc->config->get('googlecal_client_id'), |
| 223 | + 'client_secret' => $this->rc->config->get('googlecal_client_secret'), |
| 224 | + 'refresh_token' => $st['refresh'], |
| 225 | + 'grant_type' => 'refresh_token', |
| 226 | + ]); |
| 227 | + if (empty($r['access_token'])) { |
| 228 | + rcube::raise_error(['code' => 500, 'message' => 'googlecal: refresh failed: ' . ($r['error'] ?? 'unknown')], true, false); |
| 229 | + if (($r['error'] ?? '') === 'invalid_grant') { |
| 230 | + // Revoked at Google or expired: forget it so the user sees "Not connected". |
| 231 | + $this->save_state(null); |
| 232 | + } |
| 233 | + return null; |
| 234 | + } |
| 235 | + $_SESSION['googlecal_at'] = $r['access_token']; |
| 236 | + $_SESSION['googlecal_exp'] = time() + (int) ($r['expires_in'] ?? 3600); |
| 237 | + return $r['access_token']; |
| 238 | + } |
| 239 | + |
| 240 | + private function post($url, $fields) |
| 241 | + { |
| 242 | + $ctx = stream_context_create(['http' => [ |
| 243 | + 'method' => 'POST', |
| 244 | + 'header' => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json\r\n", |
| 245 | + 'content' => http_build_query($fields), |
| 246 | + 'timeout' => 15, |
| 247 | + 'ignore_errors' => true, |
| 248 | + ]]); |
| 249 | + $body = @file_get_contents($url, false, $ctx); |
| 250 | + return $body === false ? [] : (array) json_decode($body, true); |
| 251 | + } |
| 252 | +} |
0 commit comments