From 23fd14dadc6345e322a16dbaa55a48c7befceb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:14:58 +0200 Subject: [PATCH 01/12] add per-context push notification toggle and various fixes Add GOOGLEAPI_CONTEXTS_TO_SEND setting with per-context enable/disable in setup.php, add an ecmfiles extrafield for googleapiId, fix rights labels, extend token expiry buffer to 60s with better error catching, enable the scheduled watch cron job by default, and misc small fixes across oauth callback, actions and tabs. --- admin/oauthlogintokens.php | 25 ++--- admin/setup.php | 50 +++++++++- class/actions_googleapi.class.php | 91 +++++++++++++++---- class/googleapi.class.php | 7 +- config.php | 2 +- core/modules/modGoogleApi.class.php | 31 ++++++- .../modules/oauth/googleapi_oauthcallback.php | 14 +-- ...9_modGoogleApi_GoogleApiTriggers.class.php | 2 +- langs/fr_FR/googleapi.lang | 2 + lib/googleapi.lib.php | 11 ++- notifications.php | 11 ++- sql/update_0.0.0.-1.0.0.sql | 2 +- tabs/googleapi_emails.php | 2 +- tabs/usertoken.php | 32 +++++-- 14 files changed, 214 insertions(+), 68 deletions(-) diff --git a/admin/oauthlogintokens.php b/admin/oauthlogintokens.php index 47faa86..2467b33 100644 --- a/admin/oauthlogintokens.php +++ b/admin/oauthlogintokens.php @@ -36,7 +36,7 @@ use League\OAuth2\Client\Provider\GoogleUser; // Load translation files required by the page -$langs->loadLangs(["errors","admin","googleapi@googleapi"]); +$langs->loadLangs(['errors', 'oauth', 'admin', 'googleapi@googleapi']); if (!$user->admin) { accessforbidden(); @@ -107,7 +107,7 @@ // This is to use external domain name found into config file $urlwithroot = $urlwithouturlroot . DOL_URL_ROOT; // This is to use same domain name than current -//$urlwithroot=DOL_MAIN_URL_ROOT; +// $urlwithroot=DOL_MAIN_URL_ROOT; $form = new Form($db); @@ -120,7 +120,6 @@ print dol_get_fiche_head($head, 'tokengeneration', '', -1, 'technic'); - if ($user->admin) { print $langs->trans("OAuthSetupForLogin") . "

\n"; @@ -142,26 +141,30 @@ $expiredat = ''; - // Is token expired or will token expire in the next 30 seconds + // Is token expired or will token expire in the next 60 seconds if (is_object($token)) { $expire = $token->hasExpired(); - $isgoingtoexpire = (time() > ($token->getExpires() - 30)); - if ($isgoingtoexpire) { + $isgoingtoexpire = (time() > ($token->getExpires() - 60)); + if ($isgoingtoexpire || $expire) { $provider = new Google([ 'clientId' => getDolGlobalString('OAUTH_GOOGLEAPI_ID'), 'clientSecret' => getDolGlobalString('OAUTH_GOOGLEAPI_SECRET'), 'redirectUri' => dol_buildpath('/googleapi/core/modules/oauth/googleapi_oauthcallback.php', 2), ]); $grant = new RefreshToken(); - $token = $provider->getAccessToken($grant, ['refresh_token' => $tokenrefreshbackup]); - $expire = $token->hasExpired(); - storeAccessToken('GoogleApi', $token, $tokenrefreshbackup, $user->id); - setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token + try { + $token = $provider->getAccessToken($grant, ['refresh_token' => $tokenrefreshbackup]); + $expire = $token->hasExpired(); + storeAccessToken('GoogleApi', $token, $tokenrefreshbackup, $user->id); + setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token + } catch (Exception $e) { + setEventMessages($e->getMessage(), null, 'errors'); + } } $refreshtoken = $token->getRefreshToken(); $endoflife = $token->getExpires(); - $expiredat = dol_print_date($endoflife, "dayhour"); + $expiredat = dol_print_date($endoflife, "dayhour", "tzuser"); } print '
'; diff --git a/admin/setup.php b/admin/setup.php index 92f96da..6a8807e 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -24,6 +24,10 @@ // Load Dolibarr environment include '../config.php'; +/** + * @var DoliDB $db + * @var Translate $langs + */ // Libraries require_once DOL_DOCUMENT_ROOT . "/core/lib/admin.lib.php"; @@ -38,7 +42,7 @@ use Cache\Adapter\Filesystem\FilesystemCachePool; // Translations -$langs->loadLangs(["errors","admin","googleapi@googleapi"]); +$langs->loadLangs(['errors', 'admin', 'oauth', 'googleapi@googleapi']); // Access control if (! $user->admin) { @@ -95,6 +99,13 @@ 'CHECKLASTVERSION_EXTERNALMODULE' => 'CHECKLASTVERSION_EXTERNALMODULE', ]; +$googleapicontexts = json_decode(getDolGlobalString('GOOGLEAPI_CONTEXTS_TO_SEND', '{}'), true); +if (empty($googleapicontexts)) { + // set default + dolibarr_set_const($db, 'GOOGLEAPI_CONTEXTS_TO_SEND', json_encode(['standard' => true]), 'chaine', 0, '', $conf->entity); + $googleapicontexts = json_decode(getDolGlobalString('GOOGLEAPI_CONTEXTS_TO_SEND', '{}'), true); +} + /* * Actions */ @@ -108,6 +119,15 @@ //exit; } } +foreach ($googleapicontexts as $constant => $value) { + if ($action == 'contextenable_' . strtolower($constant)) { + $googleapicontexts[$constant] = true; + } + if ($action == 'contextdisable_' . strtolower($constant)) { + $googleapicontexts[$constant] = false; + } + dolibarr_set_const($db, 'GOOGLEAPI_CONTEXTS_TO_SEND', json_encode($googleapicontexts), 'chaine', 0, '', $conf->entity); +} if ($action == 'update') { $error = 0; $db->begin(); @@ -145,7 +165,7 @@ if ($action == 'edit') { print ''; - print ''; + print ''; print ''; //print $langs->trans("ListOfSupportedOauthProviders").'

'; @@ -234,6 +254,32 @@ } print '' . PHP_EOL; print '
' . PHP_EOL; + + // Contexts + print ''; + print ''; + print ''; + print ''; + print "\n"; + foreach ($googleapicontexts as $constant => $value) { + print ''; + print ''; + print '"; + print ''; + } + print "
' . $langs->trans("GoogleApiSendContexts") . '' . $langs->trans("Action") . '
' . $langs->trans('GoogleApiEnabledContext', $constant) . ''; + // $value = (isset($conf->global->$constant) ? $conf->global->$constant : 0); + if (!$value) { + print ''; + print img_picto($langs->trans("Disabled"), 'switch_off'); + print ''; + } elseif ($value) { + print ''; + print img_picto($langs->trans("Enabled"), 'switch_on'); + print ''; + } + print "
\n"; + print "
\n"; } print dol_get_fiche_end(); diff --git a/class/actions_googleapi.class.php b/class/actions_googleapi.class.php index 82231e8..fc4de40 100644 --- a/class/actions_googleapi.class.php +++ b/class/actions_googleapi.class.php @@ -137,10 +137,14 @@ public function doActions(&$parameters, $object, &$action, $hookmanager) 'googleapiremindemail' => [ 'label' => $langs->trans('GoogleApiRemindByEmail'), 'disabled' => 0, + 'mode' => 'email', + 'data-html' => img_picto('', 'googleapi@googleapi', 'class="pictofixedwidth"') . $langs->trans('GoogleApiRemindByEmail'), ], 'googleapiremindnotif' => [ 'label' => $langs->trans('GoogleApiRemindByNotification'), 'disabled' => 0, + 'mode' => 'browser', + 'data-html' => img_picto('', 'googleapi@googleapi', 'class="pictofixedwidth"') . $langs->trans('GoogleApiRemindByNotification'), ] ] ); @@ -191,9 +195,6 @@ public function completeTabsHead(&$parameters, $object, &$action, $hookmanager) { global $langs, $conf, $user; - // var_dump($parameters['object']); - // var_dump($parameters['mode']); - // var_dump($parameters); if (!isset($parameters['object']->element)) { return 0; } @@ -224,7 +225,7 @@ public function completeTabsHead(&$parameters, $object, &$action, $hookmanager) $sql .= ' AND fk_object=' . (int) $id; $resql = $this->db->query($sql); if ($resql && $obj = $this->db->fetch_object($resql)) { - $emailcount = $obj->nb; + $emailcount = (int) $obj->nb; } // If setting cache fails, this is not a problem, so we do not test result. dol_setcache($cachekey, $emailcount, 120); @@ -280,29 +281,40 @@ public function sendMail($parameters, $object, &$action, $hookmanager) $error = 0; // Error counter $contexts = explode(':', $parameters['context']); - // print '
'.print_r($parameters, true).'
'; - // print '
'.print_r($object, true).'
'; - // echo "action: " . $action;exit; + $context = $object->sendcontext ?? 'standard'; + $googleapicontexts = json_decode(getDolGlobalString('GOOGLEAPI_CONTEXTS_TO_SEND', '{}'), true); + if (empty($googleapicontexts)) { + // set default + dolibarr_set_const($this->db, 'GOOGLEAPI_CONTEXTS_TO_SEND', json_encode(['standard' => true]), 'chaine', 0, '', $conf->entity); + $googleapicontexts = json_decode(getDolGlobalString('GOOGLEAPI_CONTEXTS_TO_SEND', '{}'), true); + } + if (!array_key_exists($context, $googleapicontexts)) { + // we found a new context + $googleapicontexts = array_merge($googleapicontexts, [$context => false]); + dolibarr_set_const($this->db, 'GOOGLEAPI_CONTEXTS_TO_SEND', json_encode($googleapicontexts), 'chaine', 0, '', $conf->entity); + } + $googleapicontextsok = []; + foreach ($googleapicontexts as $key => $item) { + if ($item) { + $googleapicontextsok[] = $key; + } + } // what TODO with context 'emailing' // context notification? // https://developers.google.com/resources/api-libraries/documentation/gmail/v1/php/latest/class-Google_Service_Gmail_Message.html - if (in_array('mail', $contexts) && !in_array($object->sendcontext, ['emailing', 'notification'])) { + if (in_array($context, $googleapicontextsok)) { dol_include_once('/googleapi/lib/googleapi.lib.php'); $fromsender = $this->getArrayAddress($object->addr_from); if (!empty($user->array_options['options_googleapi_email']) && $fromsender[0]['address'] == $user->array_options['options_googleapi_email']) { $client = getGoogleApiClient($user); $service = new Google\Service\Gmail($client); - $replytosender = $this->getArrayAddress($object->reply_to); - $addrtorecipients = $this->getArrayAddress($object->addr_to); - $addrccrecipients = $this->getArrayAddress($object->addr_cc); - $addrbccrecipients = $this->getArrayAddress($object->addr_bcc); - $message = new Google\Service\Gmail\Message(); - $mime = rtrim(strtr(base64_encode($object->message), '+/', '-_'), '='); + $mime = rtrim(strtr(base64_encode($this->buildRawMessage($object)), '+/', '-_'), '='); $message->setRaw($mime); $mailsent = false; + $response = null; try { $response = $service->users_messages->send('me', $message); $mailsent = true; @@ -314,7 +326,6 @@ public function sendMail($parameters, $object, &$action, $hookmanager) if ($mailsent) { $googleapiMessageId = $response->getId(); } - //var_dump($response);exit; } else { // nothing done return 0; @@ -322,16 +333,60 @@ public function sendMail($parameters, $object, &$action, $hookmanager) } if (!$error) { - //$this->results = array('msgid' => 'azerty'); - //$this->resprints = 'A text to show'; // 1 si on a envoyé avec googleapi sinon 0 return 1; // or return 1 to replace standard code } else { - $this->errors[] = 'Error message'; + $this->errors[] = 'Error in googleapi module'; return -1; } } + /** + * Build a full RFC 822 raw message (headers + body + attachments) from a CMailFile object, + * suitable for the Gmail API 'raw' field. + * + * CMailFile only populates $object->headers/$object->message (the MIME parts used here) when + * $object->sendmode == 'mail'. On this instance MAIN_MAIL_SENDMODE is 'smtps', so those + * properties are never filled and used to be encoded empty, silently breaking every mail sent + * through this hook. Rebuilding the message here from data that CMailFile always populates + * (subject, addr_to/cc/bcc, html/msg, attachments, ...) makes this independent of sendmode. + * + * @param CMailFile $object The mail object to process + * @return string Full raw RFC 822 message + */ + private function buildRawMessage($object) + { + $subjecttouse = $object->subject; + if (!ascii_check($subjecttouse)) { + $subjecttouse = CMailFile::encodetorfc2822($subjecttouse); + } + + $headers = "To: ".CMailFile::getValidAddress($object->addr_to, 0, 1).$object->eol2; + $headers .= "Subject: ".$subjecttouse.$object->eol2; + $headers .= $object->write_smtpheaders(); + $headers .= $object->write_mimeheaders($object->filename_list, $object->mimefilename_list); + $headers = preg_replace("/([\r\n]+)$/i", "", $headers); + + $msgforbody = $object->msgishtml ? $object->html : $object->msg; + $body = $object->write_body($msgforbody); + + $filesencoded = ''; + if (!empty($object->atleastonefile) && is_array($object->filename_list)) { + $refmethod = new ReflectionMethod($object, 'write_files'); + $refmethod->setAccessible(true); + $result = $refmethod->invoke($object, $object->filename_list, $object->mimetype_list, $object->mimefilename_list, $object->cid_list); + if (is_string($result)) { + $filesencoded = $result; + } + } + + $rawmessage = $headers.$object->eol.$object->eol; // Blank line to separate headers from body + $rawmessage .= $body.$filesencoded; + $rawmessage .= "--".$object->mixed_boundary."--".$object->eol; + + return $rawmessage; + } + /** * Overloading the sendMailAfter function : replacing the parent's function with the one below * diff --git a/class/googleapi.class.php b/class/googleapi.class.php index b40851e..dc92f8a 100644 --- a/class/googleapi.class.php +++ b/class/googleapi.class.php @@ -135,6 +135,7 @@ private function chekValidPushNotificationForEvents($user, $urlfornotification) dol_include_once('/prune/vendor/autoload.php'); $client = getGoogleApiClient($user); $service = new Google\Service\Calendar($client); + $calendarId = $user->array_options['options_googleapi_calendarId'] ?: 'primary'; $sql = "SELECT rowid, userid, uuid, id, resourcetype, resourceUri, ressourceId, expirationDateTime, lastmessagenumber FROM " . MAIN_DB_PREFIX . "googleapi_watchs"; $sql .= ' WHERE userid=' . (int) $user->id . ' AND resourcetype="events"'; @@ -143,7 +144,6 @@ private function chekValidPushNotificationForEvents($user, $urlfornotification) if ($resql && $this->db->num_rows($resql) > 0) { // on a déjà quelquechose $row = $this->db->fetch_object($resql); - $calendarId = $user->array_options['options_googleapi_calendarId'] ?: 'primary'; // is it going to expire in 30min // expiration is gmt @@ -217,6 +217,7 @@ private function chekValidPushNotificationForEvents($user, $urlfornotification) $resql = $this->db->query($sql); } catch (Exception $e) { dol_syslog($e->getmessage(), LOG_ERR); + return -1; } //exit; } @@ -313,7 +314,7 @@ private function chekValidPushNotificationForContacts($user, $urlfornotification $sql .= ", 'contacts'"; $sql .= ", '" . $this->db->escape($watch->getResourceUri()) . "'"; $sql .= ", '" . $this->db->escape($watch->getResourceId()) . "'"; - //$sql .= ", '" . ($watch->getExpiration())->format('Y-m-d H:i:s') . "'"; + // $sql .= ", '" . ($watch->getExpiration())->format('Y-m-d H:i:s') . "'"; // timestamp in ms $sql .= ", '" . ($this->db->idate(substr($watch->getExpiration(), 0, -3))) . "'"; $sql .= ", '1')"; @@ -321,9 +322,11 @@ private function chekValidPushNotificationForContacts($user, $urlfornotification $resql = $this->db->query($sql); } catch (Exception $e) { dol_syslog($e->getmessage(), LOG_ERR); + return -1; } //exit; } + return 0; } diff --git a/config.php b/config.php index fe169c5..489796f 100644 --- a/config.php +++ b/config.php @@ -1,6 +1,6 @@ + * Copyright (C) 2019-2026 Frédéric France * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/core/modules/modGoogleApi.class.php b/core/modules/modGoogleApi.class.php index 60dabbb..3768a00 100644 --- a/core/modules/modGoogleApi.class.php +++ b/core/modules/modGoogleApi.class.php @@ -194,9 +194,9 @@ public function __construct($db) 'method' => 'checkExpiredSheduledWatch', 'parameters' => '', 'comment' => 'Crée si nécessaire les notifications push de GoogleApi', - 'frequency' => 12, + 'frequency' => 1, 'unitfrequency' => 3600, - 'status' => 0, + 'status' => 1, 'test' => true, ], ]; @@ -207,7 +207,7 @@ public function __construct($db) // Permission id (must not be already used) $this->rights[$r][0] = $this->numero + $r; // Permission label - $this->rights[$r][1] = 'Read myobject of Googleapi'; + $this->rights[$r][1] = 'Read object of Googleapi'; // Permission by default for new user (0/1) $this->rights[$r][3] = 1; // In php code, permission will be checked by test if ($user->rights->googleapi->level1->level2) @@ -217,14 +217,14 @@ public function __construct($db) $r++; $this->rights[$r][0] = $this->numero + $r; - $this->rights[$r][1] = 'Create/Update myobject of Googleapi'; + $this->rights[$r][1] = 'Create/Update object of Googleapi'; $this->rights[$r][3] = 1; $this->rights[$r][4] = 'write'; $this->rights[$r][5] = ''; $r++; $this->rights[$r][0] = $this->numero + $r; - $this->rights[$r][1] = 'Delete myobject of Googleapi'; + $this->rights[$r][1] = 'Delete object of Googleapi'; $this->rights[$r][3] = 1; $this->rights[$r][4] = 'delete'; $this->rights[$r][5] = ''; @@ -325,6 +325,27 @@ public function init($options = '') 'googleapi@googleapi', 'isModEnabled("googleapi")' ); + // ecmfiles + $result = $extrafields->addExtraField( + 'googleapiId', + 'GoogleApiIdId', + 'varchar', + $this->numero, + 180, + 'ecmfiles', + 0, + 0, + '', + '', + 1, + '', + 'getDolGloblaInt("GOOGLEAPI_ENABLE_EXTRAFIELDS_DEBUG") ? 3:0', + 0, + '', + '', + 'googleapi@googleapi', + 'isModEnabled("googleapi")' + ); $sql = []; diff --git a/core/modules/oauth/googleapi_oauthcallback.php b/core/modules/oauth/googleapi_oauthcallback.php index b24a952..77a7740 100644 --- a/core/modules/oauth/googleapi_oauthcallback.php +++ b/core/modules/oauth/googleapi_oauthcallback.php @@ -81,8 +81,8 @@ 'https://www.googleapis.com/auth/contacts', ]; // https://developers.google.com/identity/protocols/oauth2/scopes#docs - // $scopes[] = 'https://www.googleapis.com/auth/documents'; - // $scopes[] = 'https://www.googleapis.com/auth/drive'; + $scopes[] = 'https://www.googleapis.com/auth/documents'; + $scopes[] = 'https://www.googleapis.com/auth/drive'; // $scopes[] = 'https://www.googleapis.com/auth/spreadsheets'; $authUrl = $provider->getAuthorizationUrl([ 'prompt' => 'consent', @@ -127,14 +127,6 @@ unset($_SESSION["backtourlsavedbeforeoauthjump"]); header('Location: ' . $backtourl); + $db->close(); exit(); } - - -/* - * View - */ - -// No view at all, just actions - -$db->close(); diff --git a/core/triggers/interface_99_modGoogleApi_GoogleApiTriggers.class.php b/core/triggers/interface_99_modGoogleApi_GoogleApiTriggers.class.php index 270f627..fe71f01 100644 --- a/core/triggers/interface_99_modGoogleApi_GoogleApiTriggers.class.php +++ b/core/triggers/interface_99_modGoogleApi_GoogleApiTriggers.class.php @@ -119,7 +119,7 @@ public function runTrigger($action, $object, User $user, Translate $langs, Conf // object comes from api googleapi return 0; } - if ($object instanceof Contact && !empty($object->context['googleapi'])) { + if (get_class($object) == 'Contact' && !empty($object->context['googleapi'])) { // object comes from api googleapi return 0; } diff --git a/langs/fr_FR/googleapi.lang b/langs/fr_FR/googleapi.lang index e4a97f2..f6f1af7 100644 --- a/langs/fr_FR/googleapi.lang +++ b/langs/fr_FR/googleapi.lang @@ -47,3 +47,5 @@ GoogleApiOwnerEmail=Email Google du compte connecté GoogleApiRemindByNotification=Rappel par notification Google GoogleApiCalendarTZ=Timezone de l'agenda Google GoogleApiIdId=Google ressource ID +GoogleApiSendContexts = Contextes d'envoi +GoogleApiEnabledContext = Contexte d'envoi %s diff --git a/lib/googleapi.lib.php b/lib/googleapi.lib.php index d60ec8c..dcf5a13 100644 --- a/lib/googleapi.lib.php +++ b/lib/googleapi.lib.php @@ -68,15 +68,16 @@ function googleapiAdminPrepareHead() /** * Complete $object to change ->label and ->note before pushing event to Google Calendar. * - * @param Object $object Object event to complete + * @param ActionComm $object Object event to complete * @param Translate $langs Language object * @return void */ -function googleapi_complete_label_and_note(&$object, $langs) +function googleapi_complete_label_and_note($object, $langs) { global $conf, $db, $langs; global $dolibarr_main_url_root; + $langs->load('googleapi@googleapi'); $eventlabel = trim($object->label); // Define $urlwithroot $urlwithouturlroot = preg_replace('/' . preg_quote(DOL_URL_ROOT, '/') . '$/i', '', trim($dolibarr_main_url_root)); @@ -165,9 +166,9 @@ function getGoogleApiClient($fuser) ]); $token = retrieveAccessToken('GoogleApi', $fuser->id); - // Is token expired or will token expire in the next 30 seconds + // Is token expired or will token expire in the next 60 seconds if (is_object($token)) { - $expire = time() > ($token->getExpires() - 30); + $expire = time() > ($token->getExpires() - 60); if ($expire) { try { // il faut sauvegarder le refresh token car google ne le donne qu'une seule fois @@ -180,6 +181,8 @@ function getGoogleApiClient($fuser) $token = $provider->getAccessToken($grant, ['refresh_token' => $refreshtoken]); //$token->setRefreshToken($refreshtoken); storeAccessToken('GoogleApi', $token, $refreshtoken, $fuser->id); + } catch (Throwable $t) { + dol_syslog($t->getMessage(), LOG_ERR); } catch (Exception $e) { dol_syslog($e->getMessage(), LOG_WARNING); } diff --git a/notifications.php b/notifications.php index 0093ede..61cf1c5 100644 --- a/notifications.php +++ b/notifications.php @@ -36,6 +36,7 @@ // Load Dolibarr environment include 'config.php'; +require_once DOL_DOCUMENT_ROOT . '/core/lib/date.lib.php'; dol_include_once('/prune/vendor/autoload.php'); dol_include_once('/googleapi/lib/googleapi.lib.php'); @@ -203,8 +204,16 @@ $evt->context['googleapi'] = $db->escape($item->getId()); $res = $evt->update($fuser, 0); if ($res < 0) { - dol_syslog("googleapi notifications update actioncomm " . $evt->error, LOG_ERR); + dol_syslog("googleapi notifications update actioncomm FAILED " . $evt->error, LOG_ERR); } else { + $evt->loadReminders('', 0, false); + foreach ($evt->reminders as $reminder) { + if ($reminder->status != 0) { + continue; + } + $reminder->dateremind = dol_time_plus_duree($evt->datep, -$reminder->offsetvalue, $reminder->offsetunit); + $reminder->update($fuser); + } dol_syslog("googleapi notifications update actioncomm OK", LOG_NOTICE); } } diff --git a/sql/update_0.0.0.-1.0.0.sql b/sql/update_0.0.0.-1.0.0.sql index 23a4ed0..262a273 100644 --- a/sql/update_0.0.0.-1.0.0.sql +++ b/sql/update_0.0.0.-1.0.0.sql @@ -15,5 +15,5 @@ -- TO FIX PHP WARNINGS UPDATE llx_extrafields SET enabled='isModEnabled("googleapi")' WHERE enabled='!empty($conf->googleapi->enabled)'; -UPDATE llx_extrafields set enabled = 'isModEnabled("googleapi")' WHERE enabled = '$conf->googleapi->enabled'; +UPDATE llx_extrafields SET enabled = 'isModEnabled("googleapi")' WHERE enabled = '$conf->googleapi->enabled'; UPDATE llx_c_actioncomm SET type='module', module='googleapi@googleapi' WHERE type='system' AND module='googleapi'; diff --git a/tabs/googleapi_emails.php b/tabs/googleapi_emails.php index c44253c..8ade873 100644 --- a/tabs/googleapi_emails.php +++ b/tabs/googleapi_emails.php @@ -46,7 +46,7 @@ $toselect = GETPOST('toselect', 'array'); // Array of ids of elements selected into a list $contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'googleapilist'; // To manage different context of search $backtopage = GETPOST('backtopage', 'alpha'); // Go back to a dedicated page -$optioncss = GETPOST('optioncss', 'aZ'); // Option for the css output (always '' except when 'print') +$optioncss = GETPOST('optioncss', 'aZ'); // Option for the css output (always '' except when 'print') $id = (int) GETPOST('id', 'int'); // for member diff --git a/tabs/usertoken.php b/tabs/usertoken.php index 47b72b1..10a67bf 100644 --- a/tabs/usertoken.php +++ b/tabs/usertoken.php @@ -25,7 +25,10 @@ // Load Dolibarr environment include '../config.php'; - +/** + * @var Translate $langs + * @var string $dolibarr_main_url_root + */ require_once DOL_DOCUMENT_ROOT . '/core/lib/admin.lib.php'; require_once DOL_DOCUMENT_ROOT . '/core/lib/usergroups.lib.php'; require_once DOL_DOCUMENT_ROOT . '/user/class/user.class.php'; @@ -175,10 +178,10 @@ $refreshtoken = false; $expiredat = ''; -// Is token expired or will token expire in the next 30 seconds +// Is token expired or will token expire in the next 60 seconds if (is_object($token)) { $expire = $token->hasExpired(); - $isgoingtoexpire = (time() > ($token->getExpires() - 30)); + $isgoingtoexpire = (time() > ($token->getExpires() - 60)); if ($isgoingtoexpire) { $provider = new Google([ 'clientId' => getDolGlobalString('OAUTH_GOOGLEAPI_ID'), @@ -186,10 +189,17 @@ 'redirectUri' => dol_buildpath('/googleapi/core/modules/oauth/googleapi_oauthcallback.php', 2), ]); $grant = new RefreshToken(); - $token = $provider->getAccessToken($grant, ['refresh_token' => $tokenrefreshbackup]); - $expire = $token->hasExpired(); - storeAccessToken('GoogleApi', $token, $tokenrefreshbackup, $object->id); - setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token + try { + $token = $provider->getAccessToken($grant, ['refresh_token' => $tokenrefreshbackup]); + $expire = $token->hasExpired(); + storeAccessToken('GoogleApi', $token, $tokenrefreshbackup, $object->id); + setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token + } catch (Throwable $t) { + dol_syslog($t->getMessage(), LOG_ERR); + } catch (Exception $e) { + dol_syslog($e->getMessage(), LOG_WARNING); + setEventMessage($e->getMessage()); + } } $refreshtoken = $token->getRefreshToken(); @@ -309,9 +319,11 @@ // 'hostedDomain' => 'example.com', // optional; used to restrict access to users on your G Suite/Google Apps for Business accounts 'accessType' => 'offline', ]); - - $owner = $provider->getResourceOwner($token); - // var_dump($owner->toArray()); + try { + $owner = $provider->getResourceOwner($token); + } catch (Exception $e) { + setEventMessage($e->getMessage()); + } if (!empty($owner)) { $object->array_options['options_googleapi_Id'] = $owner->getId(); $object->array_options['options_googleapi_email'] = $owner->toArray()['email']; From 400fea1f66961703313aa5871de734b4563c5b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:21:23 +0200 Subject: [PATCH 02/12] add getGoogleDriveService helper for the Drive ECM tab --- lib/googleapi.lib.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/googleapi.lib.php b/lib/googleapi.lib.php index dcf5a13..18e5675 100644 --- a/lib/googleapi.lib.php +++ b/lib/googleapi.lib.php @@ -194,6 +194,32 @@ function getGoogleApiClient($fuser) return $client; } +/** + * Get an authenticated Google Drive service for a user's connected Google account + * + * @param User $fuser User owning the Google OAuth token + * @return \Google\Service\Drive|false Drive service, or false if the user has no valid token + */ +function getGoogleDriveService($fuser) +{ + $client = getGoogleApiClient($fuser); + if (!is_object($client)) { + return false; + } + return new \Google\Service\Drive($client); +} + +/** + * Escape a Google Drive object id for safe use inside a Drive API 'q' query string + * + * @param string $id Drive file or folder id + * @return string Escaped id + */ +function googleapiDriveEscapeId($id) +{ + return str_replace("'", "\\'", (string) $id); +} + /** * Create agenda event from task * From 25d05f6394bc3d6175bc5541f9b1d26e0ca2f79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:24:22 +0200 Subject: [PATCH 03/12] register Google Drive ECM tab and add its language keys --- core/modules/modGoogleApi.class.php | 6 ++++-- langs/en_US/googleapi.lang | 13 +++++++++++++ langs/fr_FR/googleapi.lang | 13 +++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/core/modules/modGoogleApi.class.php b/core/modules/modGoogleApi.class.php index 3768a00..3b3f42e 100644 --- a/core/modules/modGoogleApi.class.php +++ b/core/modules/modGoogleApi.class.php @@ -71,7 +71,7 @@ public function __construct($db) // Possible values for version are: 'development', 'experimental', 'dolibarr', // 'dolibarr_deprecated' or a version string like 'x.y.z' - $this->version = '1.0.2'; + $this->version = '1.1.0'; // Url to the file with your last numberversion of this module $this->url_last_version = 'https://wiki.netlogic.fr/versionmodule.php?module=googleapi'; @@ -174,7 +174,9 @@ public function __construct($db) } // Array to add new pages in new tabs - $this->tabs = []; + $this->tabs = [ + array('data' => 'ecm:+googledrive:GoogleApiDriveTab:googleapi@googleapi:$user->rights->googleapi->read:/googleapi/ecmgoogledrive.php'), + ]; // Dictionaries $this->dictionaries = []; diff --git a/langs/en_US/googleapi.lang b/langs/en_US/googleapi.lang index 123bde4..543d39c 100644 --- a/langs/en_US/googleapi.lang +++ b/langs/en_US/googleapi.lang @@ -30,3 +30,16 @@ OAUTH_GOOGLEAPI_SECRET=Oauth Google secret OAUTH_GOOGLEAPI_DESC=Go to this page then "Credentials" to create OAuth credentials GoogleApiEnablePushMeDocs=Receive push notifications from the API to update created or modified documents GoogleApiLastEventSync=Timestamp of last event sync +GoogleApiDriveTab=Google Drive +GoogleApiDriveNotConnected=Your Google account is not connected yet, or the access token has expired. +GoogleApiConnectDrive=Connect to Google Drive +GoogleApiFolder=Folder +GoogleApiRename=Rename +GoogleApiNewName=New name +GoogleApiOpenInDrive=Open in Drive +GoogleApiConfirmDeleteDriveFile=Are you sure you want to move "__FILENAME__" to the Google Drive trash? +GoogleApiUploadToThisFolder=Upload a file into this folder +GoogleApiDriveFileUploaded=File uploaded to Google Drive +GoogleApiDriveFileRenamed=Renamed on Google Drive +GoogleApiDriveFileDeleted=Moved to the Google Drive trash +GoogleApiErrorDriveApi=Google Drive API error: %s diff --git a/langs/fr_FR/googleapi.lang b/langs/fr_FR/googleapi.lang index f6f1af7..fb42a53 100644 --- a/langs/fr_FR/googleapi.lang +++ b/langs/fr_FR/googleapi.lang @@ -49,3 +49,16 @@ GoogleApiCalendarTZ=Timezone de l'agenda Google GoogleApiIdId=Google ressource ID GoogleApiSendContexts = Contextes d'envoi GoogleApiEnabledContext = Contexte d'envoi %s +GoogleApiDriveTab=Google Drive +GoogleApiDriveNotConnected=Votre compte Google n'est pas encore connecté, ou le jeton d'accès a expiré. +GoogleApiConnectDrive=Se connecter à Google Drive +GoogleApiFolder=Dossier +GoogleApiRename=Renommer +GoogleApiNewName=Nouveau nom +GoogleApiOpenInDrive=Ouvrir dans Drive +GoogleApiConfirmDeleteDriveFile=Voulez-vous vraiment mettre "__FILENAME__" à la corbeille Google Drive ? +GoogleApiUploadToThisFolder=Envoyer un fichier dans ce dossier +GoogleApiDriveFileUploaded=Fichier envoyé sur Google Drive +GoogleApiDriveFileRenamed=Renommé sur Google Drive +GoogleApiDriveFileDeleted=Déplacé vers la corbeille Google Drive +GoogleApiErrorDriveApi=Erreur API Google Drive : %s From 513b9e6614aa862034641705fc8bc274882dd4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:27:59 +0200 Subject: [PATCH 04/12] add Google Drive ECM tab page skeleton --- ecmgoogledrive.php | 100 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 ecmgoogledrive.php diff --git a/ecmgoogledrive.php b/ecmgoogledrive.php new file mode 100644 index 0000000..5e461bd --- /dev/null +++ b/ecmgoogledrive.php @@ -0,0 +1,100 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file googleapi/ecmgoogledrive.php + * \ingroup googleapi + * \brief ECM tab: browse and manage the connected user's Google Drive + */ + +// Load Dolibarr environment +include 'config.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/ecm.lib.php'; +require_once 'lib/googleapi.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +// Load translation files required by the page +$langs->loadLangs(array('ecm', 'googleapi@googleapi')); + +if (!$user->hasRight('googleapi', 'read')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); + +$permissiontowrite = $user->hasRight('googleapi', 'write'); +$permissiontodelete = $user->hasRight('googleapi', 'delete'); + +$driveservice = getGoogleDriveService($user); + +/* + * Actions + */ + +// Google Drive mutation actions (upload, rename, delete) are added here by later tasks. + +/* + * View + */ + +$morejs = array('/googleapi/js/ecmgoogledrive.js.php'); + +llxHeader('', $langs->trans("ECMArea"), '', '', 0, 0, $morejs, '', '', 'mod-googleapi page-ecmgoogledrive'); + +$head = ecm_prepare_dasboard_head(); + +print dol_get_fiche_head($head, 'googledrive', '', -1, ''); + +if (!is_object($driveservice)) { + print '
'.$langs->trans("GoogleApiDriveNotConnected").'

'."\n"; + $urltoconnect = dol_buildpath('/googleapi/core/modules/oauth/googleapi_oauthcallback.php', 1).'?backtourl='.urlencode(dol_buildpath('/googleapi/ecmgoogledrive.php', 1)); + print ''.$langs->trans("GoogleApiConnectDrive").''."\n"; +} else { + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; + if ($permissiontowrite) { + print ''."\n"; + print ''."\n"; + print ''."\n"; + print ''."\n"; + print ''."\n"; + print ''."\n"; + print ''."\n"; + } else { + print ''."\n"; + } + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; + print '
'."\n"; +} + +print dol_get_fiche_end(); + +llxFooter(); + +$db->close(); From cc32ee785d87c1ccafc87e29b3a785efcb493a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:30:39 +0200 Subject: [PATCH 05/12] add Google Drive folder tree AJAX endpoint --- core/ajax/ecmgoogledrivetree.php | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 core/ajax/ecmgoogledrivetree.php diff --git a/core/ajax/ecmgoogledrivetree.php b/core/ajax/ecmgoogledrivetree.php new file mode 100644 index 0000000..8a9c014 --- /dev/null +++ b/core/ajax/ecmgoogledrivetree.php @@ -0,0 +1,72 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file googleapi/core/ajax/ecmgoogledrivetree.php + * \ingroup googleapi + * \brief Returns the sub-folders of a Google Drive folder, for the jqueryFileTree plugin + */ + +$defines = [ + 'NOTOKENRENEWAL', + 'NOREQUIREMENU', + 'NOREQUIREHTML', + 'NOREQUIREAJAX', +]; + +// Load Dolibarr environment +include '../../config.php'; +require_once '../../lib/googleapi.lib.php'; + +top_httphead(); + +if (!$user->hasRight('googleapi', 'read')) { + accessforbidden(); +} + +$dir = GETPOST('dir', 'alpha'); +$parentid = trim((string) $dir, '/'); +if ($parentid == '') { + $parentid = 'root'; +} + +$driveservice = getGoogleDriveService($user); + +print ''."\n"; From 16ad49099896d5ac846869735434b509d1501e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:34:19 +0200 Subject: [PATCH 06/12] add Google Drive content list AJAX endpoint and JS glue for the ECM tab --- core/ajax/ecmgoogledrivelist.php | 125 +++++++++++++++++++++++++++++++ js/ecmgoogledrive.js.php | 125 +++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 core/ajax/ecmgoogledrivelist.php create mode 100644 js/ecmgoogledrive.js.php diff --git a/core/ajax/ecmgoogledrivelist.php b/core/ajax/ecmgoogledrivelist.php new file mode 100644 index 0000000..4180390 --- /dev/null +++ b/core/ajax/ecmgoogledrivelist.php @@ -0,0 +1,125 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file googleapi/core/ajax/ecmgoogledrivelist.php + * \ingroup googleapi + * \brief Returns the HTML table listing the content (folders and files) of a Google Drive folder + */ + +$defines = [ + 'NOTOKENRENEWAL', + 'NOREQUIREMENU', + 'NOREQUIREHTML', + 'NOREQUIREAJAX', +]; + +// Load Dolibarr environment +include '../../config.php'; +require_once '../../lib/googleapi.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + +top_httphead(); + +if (!$user->hasRight('googleapi', 'read')) { + accessforbidden(); +} + +$permissiontowrite = $user->hasRight('googleapi', 'write'); +$permissiontodelete = $user->hasRight('googleapi', 'delete'); + +$dir = GETPOST('dir', 'alpha'); +$parentid = ($dir == '' ? 'root' : $dir); + +$langs->loadLangs(array('googleapi@googleapi')); + +$driveservice = getGoogleDriveService($user); + +print ''."\n"; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''."\n"; + +if (is_object($driveservice)) { + try { + $query = "'".googleapiDriveEscapeId($parentid)."' in parents and trashed=false"; + $result = $driveservice->files->listFiles(array( + 'q' => $query, + 'fields' => 'files(id,name,mimeType,size,modifiedTime,webViewLink)', + 'orderBy' => 'folder,name', + 'pageSize' => 1000, + )); + + foreach ($result->getFiles() as $file) { + $isfolder = ($file->getMimeType() == 'application/vnd.google-apps.folder'); + $isnativegoogletype = (!$isfolder && strpos((string) $file->getMimeType(), 'application/vnd.google-apps.') === 0); + $fileid = $file->getId(); + $filename = $file->getName(); + + print ''; + + print ''; + + print ''; + + print ''; + + print ''; + + print ''."\n"; + } + } catch (Exception $e) { + print ''."\n"; + dol_syslog('ecmgoogledrivelist: '.$e->getMessage(), LOG_ERR); + } +} + +print '
'.$langs->trans("Name").''.$langs->trans("Size").''.$langs->trans("DateModification").'
'; + if ($isfolder) { + print img_picto('', 'folder', 'class="paddingright"'); + print ''; + print dol_escape_htmltag($filename); + print ''; + } else { + print img_mime($filename); + print dol_escape_htmltag($filename); + } + print ''; + if (!$isfolder && $file->getSize()) { + print dol_print_size((int) $file->getSize(), 1); + } + print ''; + if ($file->getModifiedTime()) { + print dol_print_date(strtotime((string) $file->getModifiedTime()), 'dayhour'); + } + print ''; + if (!$isfolder && !$isnativegoogletype) { + $downloadurl = dol_buildpath('/googleapi/core/ajax/ecmgoogledrivedownload.php', 1).'?token='.currentToken().'&fileid='.urlencode($fileid); + print ''.img_picto($langs->trans("Download"), 'download').''; + } elseif ($isnativegoogletype && $file->getWebViewLink()) { + print ''.img_picto($langs->trans("GoogleApiOpenInDrive"), 'globe').''; + } + if ($permissiontowrite) { + print ' '.img_picto($langs->trans("GoogleApiRename"), 'edit').''; + } + if ($permissiontodelete) { + print ' '.img_picto($langs->trans("Delete"), 'delete').''; + } + print '
'.dol_escape_htmltag($e->getMessage()).'
'."\n"; diff --git a/js/ecmgoogledrive.js.php b/js/ecmgoogledrive.js.php new file mode 100644 index 0000000..52d03a3 --- /dev/null +++ b/js/ecmgoogledrive.js.php @@ -0,0 +1,125 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file googleapi/js/ecmgoogledrive.js.php + * \ingroup googleapi + * \brief JS glue for the Google Drive ECM tab + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', 1); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +include '../config.php'; + +$langs->loadLangs(array('googleapi@googleapi')); + +top_httphead('application/javascript'); +?> +var ecmGoogleDriveBreadcrumb = [{id: 'root', name: 'trans("Home")); ?>'}]; + +function ecmGoogleDriveRenderBreadcrumb() +{ + var html = ''; + for (var i = 0; i < ecmGoogleDriveBreadcrumb.length; i++) { + if (i > 0) { + html += ' / '; + } + html += ''+ecmGoogleDriveBreadcrumb[i].name+''; + } + jQuery('#ecmgdrive-breadcrumb').html(html); +} + +function ecmGoogleDriveGoToBreadcrumb(index) +{ + var entry = ecmGoogleDriveBreadcrumb[index]; + ecmGoogleDriveBreadcrumb = ecmGoogleDriveBreadcrumb.slice(0, index + 1); + jQuery('#ecmgdrive_folderid').val(entry.id); + ecmGoogleDriveLoadList(entry.id); +} + +function ecmGoogleDriveNavigate(folderid, foldername) +{ + ecmGoogleDriveBreadcrumb.push({id: folderid, name: foldername}); + jQuery('#ecmgdrive_folderid').val(folderid); + ecmGoogleDriveLoadList(folderid); +} + +function ecmGoogleDriveLoadList(folderid) +{ + ecmGoogleDriveRenderBreadcrumb(); + jQuery('#ecmgdrive-filelist').html('trans("PleaseBePatient")); ?>'); + jQuery.get('', {dir: folderid, token: ''}, function(data) { + jQuery('#ecmgdrive-filelist').html(data); + }); +} + +function ecmGoogleDriveRename(fileid, currentname) +{ + var newname = prompt('trans("GoogleApiNewName")); ?>', currentname); + if (newname === null || newname === '' || newname === currentname) { + return; + } + jQuery.post('', { + action: 'renamedrivefile', + token: '', + fileid: fileid, + newname: newname + }, function() { + ecmGoogleDriveLoadList(jQuery('#ecmgdrive_folderid').val()); + }); +} + +function ecmGoogleDriveDelete(fileid, filename) +{ + var msgtemplate = 'trans("GoogleApiConfirmDeleteDriveFile")); ?>'; + if (!confirm(msgtemplate.replace('__FILENAME__', filename))) { + return; + } + jQuery.post('', { + action: 'deletedrivefile', + token: '', + fileid: fileid + }, function() { + ecmGoogleDriveLoadList(jQuery('#ecmgdrive_folderid').val()); + }); +} + +jQuery(document).ready(function() { + jQuery('#filetree').fileTree( + { + root: 'root/', + script: '?token=', + folderEvent: 'click', + multiFolder: false + }, + function(file) { + // Files are not shown in the left tree (folders only): nothing to do here. + } + ); + ecmGoogleDriveLoadList('root'); +}); From 1f94129698e18c3784c5eedb5bd9a2771f447200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:39:04 +0200 Subject: [PATCH 07/12] add Google Drive file download endpoint for the ECM tab --- core/ajax/ecmgoogledrivedownload.php | 73 ++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 core/ajax/ecmgoogledrivedownload.php diff --git a/core/ajax/ecmgoogledrivedownload.php b/core/ajax/ecmgoogledrivedownload.php new file mode 100644 index 0000000..d07c26a --- /dev/null +++ b/core/ajax/ecmgoogledrivedownload.php @@ -0,0 +1,73 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file googleapi/core/ajax/ecmgoogledrivedownload.php + * \ingroup googleapi + * \brief Streams a Google Drive file to the browser + */ + +$defines = [ + 'NOTOKENRENEWAL', + 'NOREQUIREMENU', + 'NOREQUIREHTML', + 'NOREQUIREAJAX', +]; + +// Load Dolibarr environment +include '../../config.php'; +require_once '../../lib/googleapi.lib.php'; + +if (!$user->hasRight('googleapi', 'read')) { + accessforbidden(); +} + +$fileid = GETPOST('fileid', 'alpha'); +if (empty($fileid)) { + http_response_code(400); + print 'Missing fileid'; + exit; +} + +$driveservice = getGoogleDriveService($user); +if (!is_object($driveservice)) { + http_response_code(403); + print 'Not connected to Google Drive'; + exit; +} + +try { + $metadata = $driveservice->files->get($fileid, array('fields' => 'name,mimeType,size')); + if (strpos((string) $metadata->getMimeType(), 'application/vnd.google-apps.') === 0) { + // Native Google file (Docs/Sheets/Slides/...) has no direct binary content to stream + http_response_code(400); + print 'This file type cannot be downloaded directly, open it in Google Drive instead'; + exit; + } + + $response = $driveservice->files->get($fileid, array('alt' => 'media')); + $content = $response->getBody()->getContents(); + + top_httphead($metadata->getMimeType() ? $metadata->getMimeType() : 'application/octet-stream'); + header('Content-Disposition: attachment; filename="'.dol_sanitizeFileName($metadata->getName()).'"'); + header('Content-Length: '.strlen($content)); + print $content; +} catch (Exception $e) { + dol_syslog('ecmgoogledrivedownload: '.$e->getMessage(), LOG_ERR); + http_response_code(500); + print dol_escape_htmltag($e->getMessage()); +} From fbe2613095e5e004270e3d27b4cb21b274e30f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:41:41 +0200 Subject: [PATCH 08/12] handle file upload to the current Google Drive folder --- ecmgoogledrive.php | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/ecmgoogledrive.php b/ecmgoogledrive.php index 5e461bd..3f565c8 100644 --- a/ecmgoogledrive.php +++ b/ecmgoogledrive.php @@ -51,7 +51,30 @@ * Actions */ -// Google Drive mutation actions (upload, rename, delete) are added here by later tasks. +if ($action == 'upload' && $permissiontowrite) { + if (!empty($_FILES['userfile']['tmp_name']) && is_uploaded_file($_FILES['userfile']['tmp_name'])) { + $parentid = GETPOST('folderid', 'alpha') ? GETPOST('folderid', 'alpha') : 'root'; + if (is_object($driveservice)) { + try { + $drivefile = new \Google\Service\Drive\DriveFile(); + $drivefile->setName($_FILES['userfile']['name']); + $drivefile->setParents(array($parentid)); + $driveservice->files->create($drivefile, array( + 'data' => file_get_contents($_FILES['userfile']['tmp_name']), + 'mimeType' => $_FILES['userfile']['type'] ? $_FILES['userfile']['type'] : 'application/octet-stream', + 'uploadType' => 'multipart', + )); + setEventMessages($langs->trans("GoogleApiDriveFileUploaded"), null, 'mesgs'); + } catch (Exception $e) { + setEventMessages($langs->trans("GoogleApiErrorDriveApi", $e->getMessage()), null, 'errors'); + } + } + } else { + setEventMessages($langs->trans("ErrorFieldRequired", $langs->transnoentitiesnoconv("File")), null, 'errors'); + } +} + +// Google Drive rename/delete actions are added here by the next task. /* * View From 1c6587f982edd67a44e646eae0e43dca163efe25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 14:44:09 +0200 Subject: [PATCH 09/12] handle rename and trash actions for Google Drive files --- ecmgoogledrive.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/ecmgoogledrive.php b/ecmgoogledrive.php index 3f565c8..f504239 100644 --- a/ecmgoogledrive.php +++ b/ecmgoogledrive.php @@ -74,7 +74,34 @@ } } -// Google Drive rename/delete actions are added here by the next task. +if ($action == 'renamedrivefile' && $permissiontowrite) { + $fileid = GETPOST('fileid', 'alpha'); + $newname = GETPOST('newname', 'alphanohtml'); + if ($fileid && $newname && is_object($driveservice)) { + try { + $drivefile = new \Google\Service\Drive\DriveFile(); + $drivefile->setName($newname); + $driveservice->files->update($fileid, $drivefile); + setEventMessages($langs->trans("GoogleApiDriveFileRenamed"), null, 'mesgs'); + } catch (Exception $e) { + setEventMessages($langs->trans("GoogleApiErrorDriveApi", $e->getMessage()), null, 'errors'); + } + } +} + +if ($action == 'deletedrivefile' && $permissiontodelete) { + $fileid = GETPOST('fileid', 'alpha'); + if ($fileid && is_object($driveservice)) { + try { + $drivefile = new \Google\Service\Drive\DriveFile(); + $drivefile->setTrashed(true); + $driveservice->files->update($fileid, $drivefile); + setEventMessages($langs->trans("GoogleApiDriveFileDeleted"), null, 'mesgs'); + } catch (Exception $e) { + setEventMessages($langs->trans("GoogleApiErrorDriveApi", $e->getMessage()), null, 'errors'); + } + } +} /* * View From 06aad622a0367e115c2b70572d9e0fc9b8b6013e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 15:05:27 +0200 Subject: [PATCH 10/12] load jqueryFileTree on the drive tab and decouple the file list The jqueryFileTree plugin is not loaded globally by Dolibarr core, so jQuery('#filetree').fileTree() threw "not a function" and aborted the whole ready handler, leaving both the folder tree and the file list empty. Load the plugin from $morejs before our own JS, using the same path as htdocs/ecm/index.php. Also load the file list before initializing the tree and wrap the tree init in a try/catch, so a tree failure can no longer take the file list down with it. --- ecmgoogledrive.php | 7 ++++++- js/ecmgoogledrive.js.php | 29 ++++++++++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/ecmgoogledrive.php b/ecmgoogledrive.php index f504239..034fcb8 100644 --- a/ecmgoogledrive.php +++ b/ecmgoogledrive.php @@ -107,7 +107,12 @@ * View */ -$morejs = array('/googleapi/js/ecmgoogledrive.js.php'); +// The jqueryFileTree plugin is not loaded globally by Dolibarr core: each page using it must load it +// itself (same as htdocs/ecm/index.php does). It must come before our own JS, which calls .fileTree(). +$morejs = array( + 'public/includes/jquery/plugins/jqueryFileTree/jqueryFileTree.js', + '/googleapi/js/ecmgoogledrive.js.php', +); llxHeader('', $langs->trans("ECMArea"), '', '', 0, 0, $morejs, '', '', 'mod-googleapi page-ecmgoogledrive'); diff --git a/js/ecmgoogledrive.js.php b/js/ecmgoogledrive.js.php index 52d03a3..1db6fdd 100644 --- a/js/ecmgoogledrive.js.php +++ b/js/ecmgoogledrive.js.php @@ -110,16 +110,23 @@ function ecmGoogleDriveDelete(fileid, filename) } jQuery(document).ready(function() { - jQuery('#filetree').fileTree( - { - root: 'root/', - script: '?token=', - folderEvent: 'click', - multiFolder: false - }, - function(file) { - // Files are not shown in the left tree (folders only): nothing to do here. - } - ); + // Load the file list first, and isolate the folder tree init: a failure of the jqueryFileTree + // plugin (not loaded, error, ...) must never prevent the file list from being displayed. ecmGoogleDriveLoadList('root'); + + try { + jQuery('#filetree').fileTree( + { + root: 'root/', + script: '?token=', + folderEvent: 'click', + multiFolder: false + }, + function(file) { + // Files are not shown in the left tree (folders only): nothing to do here. + } + ); + } catch (e) { + console.error('ecmgoogledrive: unable to initialize the folder tree', e); + } }); From 8809bb494ee180882e5a1b5eafb67a86e32b7ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 15:05:58 +0200 Subject: [PATCH 11/12] escape drive-provided names in the ecm tab to fix two xss holes Google Drive file and folder names are attacker-controlled: anybody sharing a folder with the connected account picks its name. Breadcrumb: the name was concatenated into a string injected with .html(), so a folder named "" ran script on every re-render. Escape it through a text node first. onclick handlers: dol_escape_js() protects the JS string literal but not the surrounding HTML attribute, and the browser HTML-decodes the attribute before parsing it as JS. A name containing "'" therefore became a real quote and broke out of the argument. Build the handler first, then HTML-escape it as a whole. Uses dol_escape_htmltag($s, 0, 0, '', 1): in its default mode dol_escape_htmltag() re-emits the literal sequence "'" untouched (it hides it behind __SIMPLEQUOTE__ so htmlentities() never encodes the &), which leaves exactly this attack open. $escapeonlyhtmltags=1 has no such carve-out. --- core/ajax/ecmgoogledrivelist.php | 14 +++++++++++--- core/ajax/ecmgoogledrivetree.php | 5 ++++- js/ecmgoogledrive.js.php | 12 +++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/core/ajax/ecmgoogledrivelist.php b/core/ajax/ecmgoogledrivelist.php index 4180390..b40f185 100644 --- a/core/ajax/ecmgoogledrivelist.php +++ b/core/ajax/ecmgoogledrivelist.php @@ -78,7 +78,13 @@ print ''; if ($isfolder) { print img_picto('', 'folder', 'class="paddingright"'); - print ''; + // Drive names are attacker-controlled. dol_escape_js() alone is not enough inside an HTML + // attribute: the browser HTML-decodes the attribute before running it as JS, so an entity + // such as ' would become a real quote and break out of the JS string. The whole handler + // is therefore also HTML-escaped. $escapeonlyhtmltags=1 is required: the default mode of + // dol_escape_htmltag() re-emits the literal sequence "'" untouched. + $onclick = "ecmGoogleDriveNavigate('".dol_escape_js($fileid)."', '".dol_escape_js($filename)."'); return false;"; + print ''; print dol_escape_htmltag($filename); print ''; } else { @@ -107,10 +113,12 @@ print ''.img_picto($langs->trans("GoogleApiOpenInDrive"), 'globe').''; } if ($permissiontowrite) { - print ' '.img_picto($langs->trans("GoogleApiRename"), 'edit').''; + $onclick = "ecmGoogleDriveRename('".dol_escape_js($fileid)."', '".dol_escape_js($filename)."'); return false;"; + print ' '.img_picto($langs->trans("GoogleApiRename"), 'edit').''; } if ($permissiontodelete) { - print ' '.img_picto($langs->trans("Delete"), 'delete').''; + $onclick = "ecmGoogleDriveDelete('".dol_escape_js($fileid)."', '".dol_escape_js($filename)."'); return false;"; + print ' '.img_picto($langs->trans("Delete"), 'delete').''; } print ''; diff --git a/core/ajax/ecmgoogledrivetree.php b/core/ajax/ecmgoogledrivetree.php index 8a9c014..16a26bd 100644 --- a/core/ajax/ecmgoogledrivetree.php +++ b/core/ajax/ecmgoogledrivetree.php @@ -59,7 +59,10 @@ )); foreach ($result->getFiles() as $folder) { print ''."\n"; diff --git a/js/ecmgoogledrive.js.php b/js/ecmgoogledrive.js.php index 1db6fdd..35c2cfb 100644 --- a/js/ecmgoogledrive.js.php +++ b/js/ecmgoogledrive.js.php @@ -42,6 +42,16 @@ ?> var ecmGoogleDriveBreadcrumb = [{id: 'root', name: 'trans("Home")); ?>'}]; +/** + * Escape a string so it can safely be concatenated into an HTML fragment that is later + * injected with .html(). Drive file and folder names are attacker-controlled: anybody + * sharing a folder with the connected account chooses its name. + */ +function ecmGoogleDriveEscapeHtml(str) +{ + return jQuery('
').text(str === null || typeof str === 'undefined' ? '' : str).html(); +} + function ecmGoogleDriveRenderBreadcrumb() { var html = ''; @@ -49,7 +59,7 @@ function ecmGoogleDriveRenderBreadcrumb() if (i > 0) { html += ' / '; } - html += ''+ecmGoogleDriveBreadcrumb[i].name+''; + html += ''+ecmGoogleDriveEscapeHtml(ecmGoogleDriveBreadcrumb[i].name)+''; } jQuery('#ecmgdrive-breadcrumb').html(html); } From f8d28cd74757599c7ef579a2af6c12b19f57ad79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Wed, 29 Jul 2026 15:24:34 +0200 Subject: [PATCH 12/12] little fix --- class/googleapi.class.php | 1 + 1 file changed, 1 insertion(+) diff --git a/class/googleapi.class.php b/class/googleapi.class.php index dc92f8a..1f4d679 100644 --- a/class/googleapi.class.php +++ b/class/googleapi.class.php @@ -336,6 +336,7 @@ private function chekValidPushNotificationForContacts($user, $urlfornotification */ private function getUuid() { + $data = '0123456789012345'; try { $data = random_bytes(16); } catch (Exception $e) {