diff --git a/mauticconnect.php b/mauticconnect.php index 653c4c4..24f3ff9 100644 --- a/mauticconnect.php +++ b/mauticconnect.php @@ -28,6 +28,13 @@ class MauticConnect extends Module const MAUTIC_ACCESS_TOKEN = 'MAUTICCONNECT_ACCESS_TOKEN'; const MAUTIC_REFRESH_TOKEN = 'MAUTICCONNECT_REFRESH_TOKEN'; const MAUTIC_TOKEN_EXPIRES = 'MAUTICCONNECT_TOKEN_EXPIRES'; + /** + * A static property to cache the access token for the duration of a single request. + * This prevents multiple API calls to verify/refresh the token within one script execution. + * @var array|null + */ + private static $runtimeCache = null; + // --- DATA-DRIVEN EVENT DEFINITIONS --- // To add a new event, simply add a new entry to this array. @@ -384,7 +391,7 @@ class MauticConnect extends Module } // ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ... - // ... makeApiRequest, refreshTokenIfNeeded ... + // ... hookActionCustomerAccountAdd, hookActionObjectCustomerUpdateAfter, syncCustomer ... // ... sendOrderEmail, findContactByEmail, and all other helpers remain mostly unchanged ... @@ -543,6 +550,128 @@ class MauticConnect extends Module return $this->context->link->getModuleLink($this->name, 'oauth2', [], true); } + /** + * Gets a guaranteed valid Mautic access token. + * + * This function uses a three-level approach for maximum reliability and efficiency: + * 1. Runtime Cache: Returns a token if one was already validated in the current script execution. + * 2. Database Cache + Live Ping: Uses the stored token but verifies it with a quick API call. + * 3. Token Refresh: If the token is expired or invalid, uses the refresh token to get a new one. + * + * This is the ONLY function you should call to get a token before making an API request. + * + * @return string The valid access token. + * @throws Exception if a valid token cannot be obtained. + */ + public function getValidAccessToken(): string + { + // Level 1: Check the runtime cache for this script execution. + if (self::$runtimeCache && time() < self::$runtimeCache['expires']) { + return self::$runtimeCache['token']; + } + + $mauticUrl = Configuration::get(self::MAUTIC_URL); + if (!$mauticUrl || !Configuration::get(self::MAUTIC_CLIENT_ID)) { + throw new DomainException('Mautic is not configured. Please check the module settings.'); + } + + $accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN); + $expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES); + + // Level 2: Check the database token and perform a live "ping" to verify it. + if ($accessToken && time() < $expiresAt) { + try { + $client = HttpClient::create(['auth_bearer' => $accessToken]); + // A simple, low-impact API call to check if the token is still valid. + $client->request('GET', $mauticUrl . '/api/contacts?limit=1'); + + // If the ping succeeds, the token is valid. Cache it for this runtime and return. + self::$runtimeCache = ['token' => $accessToken, 'expires' => $expiresAt]; + return $accessToken; + } catch (ClientExceptionInterface $e) { + if ($e->getResponse()->getStatusCode() !== 401) { + // It's a different error (e.g., 404, 500). Log and fail fast. + PrestaShopLogger::addLog( + sprintf( + 'Mautic API ping failed with status %d. Response: %s', + $e->getResponse()->getStatusCode(), + $e->getResponse()->getContent(false) + ), + 3 // Severity 3: Error + ); + throw new Exception('Mautic API is not responding correctly. Please check Mautic status.', 0, $e); + } + // If it IS a 401, the token is dead. Log it and proceed to the refresh logic below. + PrestaShopLogger::addLog('Mautic token from database was rejected (401). Forcing refresh.', 1); + } catch (Exception $e) { + PrestaShopLogger::addLog('Mautic API ping failed with a transport error: ' . $e->getMessage(), 3); + throw new Exception('Could not connect to Mautic to verify the token.', 0, $e); + } + } + + // Level 3: The token is expired or was proven invalid. We MUST refresh. + PrestaShopLogger::addLog('Attempting to refresh Mautic token.', 1); + $refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN); + + if (!$refreshToken) { + PrestaShopLogger::addLog('Cannot refresh Mautic token: Refresh Token is missing. Please re-authenticate the module.', 4); + throw new DomainException('Mautic Refresh Token is missing. Please reconnect the module.'); + } + + try { + $client = HttpClient::create(); + $response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [ + 'body' => [ + 'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID), + 'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET), + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ], + ]); + + $data = $response->toArray(); // Throws on non-2xx responses + + if (empty($data['access_token'])) { + PrestaShopLogger::addLog('Mautic refresh response was successful but did not contain an access_token. Response: ' . json_encode($data), 4); + throw new Exception('Invalid Mautic refresh response.'); + } + + // SUCCESS! Save everything to the database AND the runtime cache. + $newAccessToken = $data['access_token']; + $newRefreshToken = $data['refresh_token']; + $newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s safety buffer + + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $newAccessToken); + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $newRefreshToken); + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt); + + self::$runtimeCache = ['token' => $newAccessToken, 'expires' => $newExpiresAt]; + + PrestaShopLogger::addLog('Successfully refreshed Mautic access token.', 1); + return $newAccessToken; + } catch (Exception $e) { + $errorContext = 'CRITICAL: Mautic token refresh failed: ' . $e->getMessage(); + if ($e instanceof ClientExceptionInterface) { + $errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false); + } + + PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL + + // Invalidate the expired access token. DO NOT touch the refresh token unless Mautic + // explicitly tells us it's invalid. + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); + + if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) { + // This is the only case where the refresh token is truly dead. + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); + throw new Exception('Mautic refresh token is invalid or expired. Please reconnect the module in the configuration page.', 0, $e); + } + + // For all other errors (network issues, Mautic down), we keep the refresh token to try again later. + throw new Exception('Could not refresh Mautic token. The system will try again on the next run.', 0, $e); + } + } /* Core function to make any request to the Mautic API using Symfony HTTP Client. * It handles token refreshing, authentication headers, and error checking. @@ -555,9 +684,18 @@ class MauticConnect extends Module */ private function makeApiRequest($endpoint, $method = 'GET', $data = []) { - $this->refreshTokenIfNeeded(); + try { + $accessToken = $this->getValidAccessToken(); + } catch (Exception $e) { + // The getValidAccessToken function already logged the critical error. + // We just log the consequence and stop this specific operation. + PrestaShopLogger::addLog( + 'valid Mautic token could not be obtained. Error: ' . $e->getMessage(), + 3 // Severity 3: Error + ); + return false; // Or handle the failure as needed + } - $accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN); $mauticUrl = Configuration::get(self::MAUTIC_URL); /** @var \Symfony\Contracts\HttpClient\HttpClientInterface $client */ @@ -599,27 +737,97 @@ class MauticConnect extends Module } /** - * Checks if the access token is expired and uses the refresh token to get a new one. + * Verifies the Mautic API connection by making a lightweight API call. + * If the call fails due to an expired token (401), it triggers a token refresh. + * This function should be called before making any critical API calls to Mautic. * - * @throws Exception if refreshing the token fails. + * @throws Exception if the connection is invalid and cannot be refreshed. */ - private function refreshTokenIfNeeded() + public function verifyConnectionAndRefresh(): void + { + $accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN); + $mauticUrl = Configuration::get(self::MAUTIC_URL); + + if (!$accessToken || !$mauticUrl) { + // Not configured yet, nothing to do. + PrestaShopLogger::addLog('Mautic connection check skipped: module is not configured.', 1); + return; + } + + // First, check the local expiry time. If it's not expired, do a quick "ping". + $expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES); + if (time() < $expiresAt) { + try { + $client = HttpClient::create([ + 'auth_bearer' => $accessToken, + ]); + // A simple, low-impact API call to check if the token is still valid. + $client->request('GET', $mauticUrl . '/api/contacts?limit=1'); + + // If the above line doesn't throw, the token is valid. We are done. + return; + } catch (ClientExceptionInterface $e) { + // Check if it's an authentication error (token expired/revoked) + if ($e->getResponse()->getStatusCode() === 401) { + PrestaShopLogger::addLog('Mautic access token is invalid or expired. Attempting to refresh.', 1); // Severity 1: Informational + // The token is invalid, proceed to refresh it below. + } else { + // Another client error (e.g., 404 Not Found, 403 Forbidden). Log and re-throw. + PrestaShopLogger::addLog( + sprintf( + 'Mautic API ping failed with status code %d. Error: %s', + $e->getResponse()->getStatusCode(), + $e->getResponse()->getContent(false) + ), + 3 // Severity 3: Error + ); + throw new Exception('Mautic API ping failed. Please check module configuration and Mautic API status.', 0, $e); + } + } catch (Exception $e) { + // Other errors like network issues. + PrestaShopLogger::addLog('Mautic API ping failed with a general error: ' . $e->getMessage(), 3); + throw new Exception('Could not connect to Mautic for ping test.', 0, $e); + } + } + + // If the code reaches here, it's either because the token is expired locally + // or the ping returned a 401. Time to refresh. + try { + $this->refreshToken(true); // Force refresh + } catch (Exception $e) { + // The refresh itself failed. The original exception from refreshToken is already logged. + // We re-throw it to stop the current process. + throw $e; + } + } + + + /** + * Checks if the access token is expired and uses the refresh token to get a new one. + * This version is made more resilient and provides detailed logging. + * + * @param bool $force Forces a refresh attempt even if the token is not expired locally. + * @return string The new expiry date. + * @throws Exception if refreshing the token fails critically. + */ + public function refreshToken(bool $force = false): string { $expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES); - if (time() < $expiresAt) { - return; // Token is still valid + if ((time() + 3600) < $expiresAt && !$force) { + return date("Y-m-d H:i:s", $expiresAt); // Token is still valid } $mauticUrl = Configuration::get(self::MAUTIC_URL); $refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN); - if (!$refreshToken) { - throw new Exception('Cannot refresh token: Refresh token is missing.'); + if (!$mauticUrl || !$refreshToken) { + // Log this critical state + PrestaShopLogger::addLog('Cannot refresh Mautic token: Mautic URL or Refresh Token is missing. Please re-authenticate.', 4); // Severity 4: Critical + throw new DomainException('Cannot refresh token: Mautic URL or Refresh token is missing in configuration.'); } $client = HttpClient::create(); - $postData = [ 'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID), 'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET), @@ -629,27 +837,50 @@ class MauticConnect extends Module try { $response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [ - // Symfony client correctly encodes this as application/x-www-form-urlencoded 'body' => $postData, ]); - $data = $response->toArray(); + $data = $response->toArray(); // Throws exception on non-2xx response if (!isset($data['access_token'])) { + PrestaShopLogger::addLog('Mautic refresh response did not contain an access_token. Response: ' . json_encode($data), 4); throw new Exception('Mautic response did not contain an access_token.'); } // Success! Save the new tokens and expiry time. Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $data['access_token']); Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $data['refresh_token']); - $expiresAt = time() + (int)$data['expires_in'] - 60; // 60s buffer - Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $expiresAt); + $newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s buffer for safety + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt); + + PrestaShopLogger::addLog('Successfully refreshed Mautic access token. New expiry: ' . date("Y-m-d H:i:s", $newExpiresAt), 1); + return date("Y-m-d H:i:s", $newExpiresAt); } catch (Exception $e) { - // Critical failure: we can no longer authenticate. + // This is the CRITICAL change. We log with high severity but DO NOT delete the refresh token. + + $errorContext = 'Mautic token refresh failed: ' . $e->getMessage(); + // Try to get the specific API error message from Mautic for better debugging + if ($e instanceof ClientExceptionInterface) { + $errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false); + } + + PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL ERROR + + // Invalidate the CURRENT access token, as it's clearly not working. + // DO NOT delete the refresh token. It's our only chance to recover. Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); - Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); - throw new Exception('Failed to refresh Mautic token. Please reconnect the module. Error: ' . $e->getMessage(), 0, $e); + + // If the error response indicates the refresh token itself is invalid, then we can't recover. + if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) { + // Now it's truly a fatal error. The refresh token is dead. + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); + throw new Exception('Failed to refresh Mautic token because the refresh token is invalid or expired. Please reconnect the module in the configuration page. Original Error: ' . $e->getMessage(), 0, $e); + } + + // For other errors (network, Mautic down), we throw an exception to halt the current operation, + // but we preserve the refresh token so the system can try again later. + throw new Exception('Failed to refresh Mautic token. The system will try again later. Error: ' . $e->getMessage(), 0, $e); } }