* Copyright (C) 2025-2026 MDW * Copyright (C) 2025 Frédéric France * * This program is free software; you can redistribute it and/or modifyion 2.0 (the "License"); * it under the terms of the GNU General Public License as published bypliance with the License. * the Free Software Foundation; either version 3 of the License, or * * 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 . * or see https://www.gnu.org/ */ include_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; /** * Class ExternalModules */ class ExternalModules { /** * @var int Pagination: current page */ public $no_page; /** * @var int Pagination: display per page */ public $per_page; /** * @var int The current categorie */ public $categorie; /** * @var string The search keywords */ public $search; // setups /** * @var string */ /** * @var string GitHub YAML file URL */ public $file_source_url; /** * @var string Cache file path for GitHub modules YAML file content (local) */ public $cache_file; /** * // the url of this page * @var string */ public $url; /** * @var string */ public $shop_url; // the url of the shop /** * @var string */ public $lang; // the integer representing the lang in the store /** * @var bool */ public $debug_api; // useful if no dialog /** * @var string */ public $dolistore_api_url; /** * @var string */ public $dolistore_api_key; /** * @var int */ public $dolistoreApiStatus; /** * @var string */ public $dolistoreApiError; /** * @var int */ public $githubFileStatus; /** * @var string */ public $githubFileError; /** * @var string */ public $error; /** * @var int // number of online providers */ public $numberOfProviders; /** * @var array|null */ public $products; /** * @var int Total number of products */ public $numberTotalOfProducts; /** * @var int Total number of pages */ public $numberTotalOfPages; /** * @var int Number of products displayed on the page. */ public $numberOfProducts; /** * Constructor * * @param boolean $debug Enable debug of request on screen */ public function __construct($debug = false) { global $langs; $this->debug_api = $debug; $this->url = DOL_URL_ROOT.'/admin/modules.php?mode=marketplace'; // For dolistore modules $this->dolistore_api_url = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_SRV', 'https://www.dolistore.com/api/'); // 'https://www.dolistore.com/api/', 'https://admin2.dolibarr.org/api/index.php/marketplace/' $this->dolistore_api_key = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_KEY', 'dolistorepublicapi'); $this->shop_url = getDolGlobalString('MAIN_MODULE_DOLISTORE_SHOP_URL', 'https://www.dolistore.com'); // For community modules $this->file_source_url = "https://raw.githubusercontent.com/Dolibarr/dolibarr-community-modules/refs/heads/main/index.yaml"; $this->cache_file = DOL_DATA_ROOT.'/admin/temp/remote_github_modules_file.yaml'; $lang = $langs->defaultlang; $lang_array = array('en_US', 'fr_FR', 'es_ES', 'it_IT', 'de_DE'); if (!in_array($lang, $lang_array)) { $lang = 'en_US'; } $this->lang = $lang; } /** * loadRemoteSources * * @param boolean $debug Enable debug of request on screen * @return void */ public function loadRemoteSources($debug = false) { // Check access to Community repo if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) { $cachedelayforgithubrepo = getDolGlobalInt('MAIN_REMOTE_GITHUBREPO_CACHE_DELAY', 86400); $this->getRemoteYamlFile($this->file_source_url, $cachedelayforgithubrepo); $this->githubFileError = $this->error; $this->githubFileStatus = dol_is_file($this->cache_file) ? 1 : 0; } // Check access to Dolistore API /api/categories -> /api/index.php/marketplace/categories if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) { $this->dolistoreApiStatus = $this->checkApiStatus(); } // Count the number of online providers $this->numberOfProviders = $this->dolistoreApiStatus + $this->githubFileStatus; } /** * Test if we can access to remote Dolistore market place. * * @param string $resource Resource relative URL ('categories' or 'products') * @param array|false $options Options for the request * @return array{status_code:int,response:null|string|array} */ public function callApi($resource, $options = false) { // If no dolistore_api_key is set, we can't access the API if (empty($this->dolistore_api_key) || empty($this->dolistore_api_url)) { return array('status_code' => 0, 'response' => null); } // Add basic auth if needed $basicAuthLogin = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_LOGIN'); $basicAuthPassword = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_PASSWORD'); $httpheader = array('DOLAPIKEY: '.$this->dolistore_api_key); if ($basicAuthLogin) { $httpheader[] = 'Authorization: Basic '.base64_encode($basicAuthLogin.':'.$basicAuthPassword); } $url = $this->dolistore_api_url . (preg_match('/\/$/', $this->dolistore_api_url) ? '' : '/') . $resource; $options['apikey'] = $this->dolistore_api_key; if ($options) { $url .= '?' . http_build_query($options); } $response = getURLContent($url, 'GET', '', 1, $httpheader, array('https'), 0, -1, 5, 5); $status_code = $response['http_code']; $body = 'Error'; if ($status_code == 200) { $body = $response['content']; $body = json_decode($body, true); $returnarray = array( 'status_code' => $status_code, 'response' => $body ); } else { $returnarray = array( 'status_code' => $status_code, 'response' => $body ); if (!empty($response['curl_error_no'])) { $returnarray['curl_error_no'] = $response['curl_error_no']; } if (!empty($response['curl_error_msg'])) { $returnarray['curl_error_msg'] = $response['curl_error_msg']; } } return $returnarray; } /** * Fetch modules from a cache YAML file * @param array $options Options for filter * * @return list|string|null>> List of modules */ public function fetchModulesFromFile($options = array()) { $modules = array(); if (!empty($this->cache_file) && file_exists($this->cache_file)) { dol_syslog(__METHOD__ . " - Loading cache file: " . $this->cache_file, LOG_DEBUG); $content = file_get_contents($this->cache_file); if ($content !== false) { $modules = $this->readYaml($content); } else { dol_syslog(__METHOD__ . " - Error reading cache file", LOG_ERR); } } return $modules; } /** * Generate HTML for categories and their children. * @param int $active The active category id * * @return string HTML string representing the categories and their children. */ public function getCategories($active = 0) { $organized_tree = array(); $html = ''; $data = [ 'lang' => $this->lang ]; $current = $active; $resCategories = $this->callApi('categories', $data); if (isset($resCategories['response']) && is_array($resCategories['response'])) { $organized_tree = $resCategories['response']; } else { return $html ; } $html = ''; foreach ($organized_tree as $key => $value) { if ($value['label'] != "Versions" && $value['label'] != "Specials") { $html .= ''; $html .= '' . $value['label'] . ''; if (isset($value['children'])) { $html .= '
    '; usort($value['children'], $this->buildSorter('position')); foreach ($value['children'] as $key_children => $value_children) { $html .= ''; $html .= '' . $value_children['label'] . ''; $html .= ''; } $html .= '
'; } $html .= ''; } } return $html; } /** * Generate HTML for products. * * @param array $options Options for the request * @return string|null HTML string representing the products. */ public function getProducts($options) { global $langs; $langs->load("products"); $html = ""; $last_month = dol_now() - (30 * 24 * 60 * 60); $dolibarrversiontouse = DOL_VERSION; // full string with version $this->products = array(); $this->categorie = $options['categorie'] ?? 0; $this->per_page = $options['per_page'] ?? 11; $this->no_page = $options['no_page'] ?? 1; $this->search = $options['search'] ?? ''; $this->per_page = 11; // We fix number of products per page to 11 // Length of $search must be at least 2 characters if (!empty($this->search) && strlen(str_replace(' ', '', (string) $this->search)) < 2) { $html .= ''; $html .= '

'; $html .= $langs->trans("SearchStringMinLength").'...'; $html .= '

'; $html .= ''; return $html; } $data = [ 'categorieid' => $this->categorie, 'limit' => $this->per_page, 'page' => $this->no_page, 'search' => $this->search, 'lang' => $this->lang ]; $this->numberTotalOfProducts = 0; // Special case of category goodies if ($this->categorie == 87) { $html = ''; return $html; } // Fetch the products from Dolistore source $dolistoreProducts = array(); $dolistoreProductsTotal = 0; if ($this->dolistoreApiStatus > 0 && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) { $getDolistoreProducts = $this->callApi('products', $data); if (!isset($getDolistoreProducts['response']) || !is_array($getDolistoreProducts['response']) || ($getDolistoreProducts['status_code'] != 200 && $getDolistoreProducts['status_code'] != 201)) { $dolistoreProducts = array(); $dolistoreProductsTotal = 0; } else { $dolistoreProducts = $this->adaptData($getDolistoreProducts['response']['products'], 'dolistore'); $dolistoreProductsTotal = (int) $getDolistoreProducts['response']['total']; $this->numberTotalOfProducts += $dolistoreProductsTotal; } } // Fetch the products from the github repo $fileProducts = array(); $fileProductsTotal = 0; if (!empty($this->githubFileStatus) && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) { $fileProducts = $this->fetchModulesFromFile($data); // Return an array with all modules from the cache filecontent in $data $fileProducts = $this->adaptData($fileProducts, 'githubcommunity'); $fileProducts = $this->applyFilters($fileProducts, $data); $fileProductsTotal = $fileProducts['total']; $this->numberTotalOfProducts += $fileProductsTotal; $fileProducts = $fileProducts['data']; } // Number of pages $this->numberTotalOfPages = (int) ceil(max($fileProductsTotal / $this->per_page, $dolistoreProductsTotal / $this->per_page)); // Merge both sources (github community modules have priority on dolistore). $this->products = $dolistoreProducts; foreach ($fileProducts as $fileProduct) { $id = $fileProduct['id']; if ($id > 0) { if (empty($this->products[$id])) { // Not already present in array array_unshift($this->products, $fileProduct); } else { $this->products[$id] = $fileProduct; $this->products[$id]['category'] = $fileProduct['category']; } } else { array_unshift($this->products, $fileProduct); } } $i = 0; foreach ($this->products as $product) { $i++; // check new product ? $newapp = ''; if ($last_month < strtotime($product['datec']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') { $newapp .= ''.$langs->trans('New').' '; } // check updated ? if ($newapp == '' && $last_month < strtotime($product['tms']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') { $newapp .= ''.$langs->trans('UpdatedRecently').' '; } // add image or default ? if ($product["cover_photo_url"] != '' && $product["cover_photo_url"] != '#') { $images = 'trans('Version').' '.$product["module_version"]).'">'; $images .= ''; } else { $images = ''; } // Set and check version $version = ''; $compatible = ''; if ($product["status"] == 'soon' || $product["status"] == 'development' || $product["status"] == 'experimental') { $version = ''.$langs->trans("NotYetAvailable").' - '.$langs->trans("StillInDevelopment").''; $compatible = 'NotCompatible'; } elseif ($this->versionCompare($product["dolibarr_min"], $dolibarrversiontouse) <= 0) { if (!empty($product["dolibarr_max"]) && $product["dolibarr_max"] != 'auto' && $product["dolibarr_max"] != 'unknown' && $this->versionCompare($product["dolibarr_max"], $dolibarrversiontouse) >= 0) { // Compatible $version = ''.$langs->trans( 'CompatibleUpTo', $dolibarrversiontouse, $product["dolibarr_min"], $product["dolibarr_max"] ).''; $compatible = ''; } else { // Never compatible, module expired $version = ''.$langs->trans( 'NotCompatible', $dolibarrversiontouse, $product["dolibarr_min"], $product["dolibarr_max"] ).''; $compatible = 'NotCompatible'; } } else { if ($product["dolibarr_min"] == 'auto' || $product["dolibarr_min"] != 'unknown') { // Never compatible, module expired $version = ''.$langs->trans( 'NotCompatible', $dolibarrversiontouse, $product["dolibarr_min"], $product["dolibarr_max"] ).''; $compatible = 'NotCompatible'; } else { // Need update $version = ''.$langs->trans( 'CompatibleAfterUpdate', $dolibarrversiontouse, $product["dolibarr_min"], $product["dolibarr_max"] ).''; $compatible = 'NotCompatible'; } } // free or pay ? $install_link = ''; if (array_key_exists('price_ht', $product) && price2num($product["price_ht"]) > 0) { $price = '

'.price(price2num($product["price_ht"], 'MT'), 0, $langs, 1, -1, -1, 'EUR').' '.$langs->trans("HT").'

'; $download_link = ''; $download_link .= img_picto('', 'url', 'class="size2x paddingright"'); $download_link .= ''; } else { $download_link = '#'; if ($product['source'] === 'dolistore') { // 0 on dolistore may mean 0 or a complementary fee to subscribe $urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]); $price = '

'.$langs->trans('SeeOnDoliStore').'

'; } elseif ($product['source'] === 'githubcommunity') { if (array_key_exists('price_ht', $product) && empty($product['price_ht'])) { if ($product['status'] == 'soon') { $price = '

'.$langs->trans('StillInDevelopment').'

'; } else { $price = '

'.$langs->trans('Free').'

'; } } else { if ($product["dolistore-download"]) { $price = '

'.$langs->trans('SeeOnDoliStore').'

'; } else { $price = '

'.$langs->trans('Unknown').'

'; } } } else { $price = '

'.$langs->trans('Unknown').'

'; } if ($product['source'] === 'githubcommunity') { $download_link = ''; $download_link .= img_picto('', 'file-code', 'class="size2x paddingright colorgrey"'); $download_link .= ''; $urlview = $product["dolistore-download"]; // View on Dolistore if ($urlview) { $download_link .= ''; $download_link .= img_picto('', 'url', 'class="size2x"'); $download_link .= ''; } if (!empty($product['direct-download']) && $product['direct-download'] == 'yes') { $reg = array(); if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) { $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1]; $download_link .= ''; $download_link .= img_picto('', 'download', 'class="size2x paddingright"'); $download_link .= ''; } } } elseif ($product['source'] === 'dolistore') { $urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]); $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id']; $download_link = ''; $download_link .= img_picto('', 'url', 'class="size2x"'); $download_link .= ''; $download_link .= ''; $download_link .= img_picto('', 'download', 'class="size2x paddingright"'); $download_link .= ''; } // Direct install if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || $product['source'] === 'dolistore') { $urldownload = ''; if ($product['source'] === 'githubcommunity') { $current_version = $product['module_version'] ?? ''; $module_name = strtolower(preg_replace('/@.*$/', '', $product['ref'] ?? '')); // Remove "-" followed by current version at the end of the string if it exists $module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name); $urldownload = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip'; $reg = array(); $urlview = $product["dolistore-download"]; // View on Dolistore // For community modules, we download from community repo. // But we can force to download from dolistore if MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY is set (less reliable, less up to date) if ($product["direct-download"] == 'dolistore' || getDolGlobalString("MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY")) { if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) { $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1]; } } } if ($product['source'] === 'dolistore') { $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id']; } $disableInstall = ($compatible === 'NotCompatible') && !getDolGlobalInt('MAIN_FEATURES_LEVEL'); // $disableInstall = false; // TODO: remove this. $disableInfo = $disableInstall ? dol_string_nohtmltag($version) : ''; $fields = ['action' => 'install', 'token' => newToken()]; foreach ($product as $key => $value) { $fields['producttoinstall['.$key.']'] = $value; } $installConfirmMessage = $langs->transnoentities( "extModuleConfirmInstallText", $product['label'] ?? '', $product['module_version'] ?? '', $product['ref'] ?? '', !empty($product['tms']) ? dol_print_date($product['tms'], '%d/%m/%Y') : '' ); $installConfirmMessage .= $langs->trans("Path").' : '.$urldownload; $install_link = ''; } } // Output the line $html .= ''; // Logo $html .= '
'; $html .= $newapp.$images; // No dol_escape_htmltag, it is already escape html $html .= '
'; // Description $html .= '

'; $html .= dolPrintHTML(dol_string_nohtmltag(ucfirst($product["label"]))); if (!empty($product['author']) && $product['author'] != 'unkownauthor') { $html .= '   -   '.img_picto('', 'company', 'class="pictofixedwidth"'); if (!empty($product['author_url'])) { $html .= ''.$product['author'].''; } else { $html .= $product['author']; } $html .= ''; } $html .= '
'; $html .= $version; // Version Dolibarr. No dol_escape_htmltag, it is already escape html $html .= ''; $html .= '

'; $html .= ' '; if (empty($product['tms'])) { $html .= img_picto($langs->trans('DateCreation'), 'calendar', 'class="pictofixedwidth"').''.$langs->trans("DateCreation").': '; $html .= (!empty($product['datec']) ? dol_print_date(dol_stringtotime($product['datec']), 'day') : $langs->trans("Unknown")).''; } else { $html .= img_picto($langs->trans('DateModification'), 'calendar', 'class="pictofixedwidth"').''.dol_print_date(dol_stringtotime($product['tms']), 'day').''; } $html .= '     '; $html .= '
'; if ($product["source"] == 'dolistore') { $html .= ''; } elseif ($product["source"] == 'githubcommunity') { $html .= img_picto($langs->trans('Source').': GitHub community repo', 'group', 'class="pictofixedwidth valignmiddle"'); } else { $html .= img_picto($langs->trans('Source').': '.$langs->trans('Other'), 'generic', 'class="pictofixedwidth"'); } $html .= '
'; $html .= $langs->trans('Ref').' '.dolPrintHTML(preg_replace('/@.*$/', '', $product["ref"])); $html .= '

'; $html .= ' '; if (!empty($product['phpmin']) && $product['phpmin'] != 'unknown') { $html .= ' PHP min '.$product['phpmin'].''; } if (!empty($product['phpmax']) && $product['phpmax'] != 'unknown') { $html .= ' PHP max '.$product['phpmax'].''; } $html .= '
'; $html .= '
'; $html .= '
'.dolPrintHTML(dol_string_nohtmltag($product["description"])).'
'; $html .= ''; if (getDolOptimizeSmallScreen()) { $html .= ''; } // Price - do not load if display none $html .= ''; $html .= $price; if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || ($product['source'] === 'dolistore' && empty((float) $product['price_ht']))) { if ($install_link) { $html .= $install_link; } } if (!getDolOptimizeSmallScreen()) { $html .= ''; $html .= ''; } // Links $html .= $download_link; $html .= ''; $html .= ''; } if (empty($this->products)) { $colspan = (getDolOptimizeSmallScreen() ? 1 : 3); $langs->load("website"); $html .= ''; $html .= '

'; $html .= $langs->trans("noResultsWereFound").'...'; $html .= '

'; $html .= ''; } // JS for confirm install $confirmLabel = dol_escape_js($langs->trans("Install")); $cancelLabel = dol_escape_js($langs->trans("Cancel")); $html .= ''; $this->numberOfProducts = count($this->products); return $html; } /** * Sort an array by a key * * @param string $key Key to sort by * @return Closure(array, array): int */ public function buildSorter(string $key): Closure { return /** * @param array $a * @param array $b * @return int */ function (array $a, array $b) use ($key) { $valA = isset($a[$key]) && is_scalar($a[$key]) ? (string) $a[$key] : ''; $valB = isset($b[$key]) && is_scalar($b[$key]) ? (string) $b[$key] : ''; return strnatcmp($valA, $valB); }; } /** * version compare * * @param string $v1 version 1 * @param string $v2 version 2 * @return int result of compare */ public function versionCompare($v1, $v2) { // Clean v1 and v2 $v1 = str_replace(array('v', 'V'), '', $v1); $v2 = str_replace(array('v', 'V'), '', $v2); $v1 = explode('.', $v1); $v2 = explode('.', $v2); $ret = 0; $level = 0; $count1 = count($v1); $count2 = count($v2); $maxcount = max($count1, $count2); while ($level < $maxcount) { $operande1 = isset($v1[$level]) ? $v1[$level] : 'x'; $operande2 = isset($v2[$level]) ? $v2[$level] : 'x'; $level++; if (strtoupper($operande1) == 'X' || strtoupper($operande2) == 'X' || $operande1 == '*' || $operande2 == '*') { break; } if ($operande1 < $operande2) { $ret = -$level; break; } if ($operande1 > $operande2) { $ret = $level; break; } } //print join('.',$versionarray1).'('.count($versionarray1).') / '.join('.',$versionarray2).'('.count($versionarray2).') => '.$ret.'
'."\n"; return $ret; } // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** * get previous link * * @param string $text symbol previous * @return string html previous link */ public function get_previous_link($text = '<<') { // phpcs:enable return ''.dol_escape_htmltag($text).''; } // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** * get next link * * @param string $text symbol next * @return string html next link */ public function get_next_link($text = '>>') { // phpcs:enable return ''.dol_escape_htmltag($text).''; } // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** * get previous url * * @return string previous url */ public function get_previous_url() { // phpcs:enable $param_array = array(); if ($this->no_page > 1) { $sub = 1; } else { $sub = 0; } if (!empty($this->search)) { $param_array['search_keyword'] = $this->search; } $param_array['no_page'] = $this->no_page - $sub; if ($this->categorie != 0) { $param_array['categorie'] = $this->categorie; } $param = http_build_query($param_array); return $this->url."&".$param; } // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** * get next url * * @return string next url */ public function get_next_url() { // phpcs:enable $param_array = array(); if ($this->products !== null && count($this->products) < $this->per_page) { $add = 0; } else { $add = 1; } if (!empty($this->search)) { $param_array['search_keyword'] = $this->search; } $param_array['no_page'] = $this->no_page + $add; if ($this->categorie != 0) { $param_array['categorie'] = $this->categorie; } $param = http_build_query($param_array); return $this->url."&".$param; } /** * Generate pagination for navigating through pages of products. * * @return string HTML string representing the pagination. */ public function getPagination() { global $langs; $page = $this->no_page; $limit = $this->per_page; $totalnboflines = $this->numberTotalOfProducts ?: 0; $num = $this->numberOfProducts; $html = ""; // Show navigation bar $pagelist = ''; if ($page > 0 || $num > $limit) { if ($totalnboflines) { if ($limit > 0) { $nbpages = $this->numberTotalOfPages; } else { $nbpages = 1; } // Show previous page if ($page > 1) { $pagelist .= ''; } $pagelist .= ''; // Show next page if ($page < $nbpages) { $pagelist .= ''; } } } if ($limit || $pagelist) { $html .= ''; } $html .= ajax_autoselect('.page_input'); return $html; } /** * Check the status code of the request * * @param array{status_code:int,response:null|string|array{curl_error_msg:string,errors:array{code:int,message:string}[]}} $request Response elements of CURL request * @return string|null */ protected function checkStatusCode($request) { // Define error messages $error_messages = [ 204 => 'No content', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 405 => 'Method Not Allowed', 500 => 'Internal Server Error', ]; // If status code is 200 or 201, return an empty string if ($request['status_code'] === 200 || $request['status_code'] === 201) { return ''; } // Get the predefined error message or use a default one $error_message = $error_messages[$request['status_code']] ?? 'Unexpected HTTP status: ' . $request['status_code']; // Append error details if available if (!empty($request['response']) && isset($request['response']['errors']) && is_array($request['response']['errors'])) { foreach ($request['response']['errors'] as $error) { $error_message .= ' - (Code ' . $error['code'] . '): ' . $error['message']; } } if (!empty($request['curl_error_msg'])) { $error_message .= ' - ' . $request['curl_error_msg']; } // Return the formatted error message return sprintf('This call to the API failed and returned an HTTP status of %d. That means: %s.', $request['status_code'], $error_message); } /** * Get YAML file from remote source and put it into the cache file * * @param string $file_source_url URL of the remote source * @param int $cache_time Cache time * @return bool|string File content */ public function getRemoteYamlFile($file_source_url, $cache_time) { $yaml = ''; $cache_file = $this->cache_file; $cache_folder = dirname($cache_file); // Check if cache directory exists if (!dol_is_dir($cache_folder)) { dol_mkdir($cache_folder, DOL_DATA_ROOT); } if (!file_exists($cache_file) || filemtime($cache_file) < (dol_now() - $cache_time)) { // We get remote url $addheaders = array(); $result = getURLContent($file_source_url, 'GET', '', 1, $addheaders); // TODO Force timeout to 5 s on both connect and response. if (!empty($result) && $result['http_code'] == 200) { $yaml = $result['content']; $result = file_put_contents($cache_file, $yaml); if ($result === false) { $this->error = 'Failed to create cache file: ' . $cache_file; } else { dolChmod($cache_file); } } } else { $yaml = file_get_contents($cache_file); } return $yaml; } /** * Read a YAML string and convert it to an array * * @param string $yaml YAML string * @return list|string|null>> Parsed array representation */ public function readYaml($yaml) { $data = []; $currentPackage = null; $currentSection = null; foreach (explode("\n", trim($yaml)) as $line) { $trimmedLine = trim($line); // Ignore empty lines and comments if ($trimmedLine === '' || strpos($trimmedLine, '#') === 0) { continue; } // Match a new package entry (e.g., "- modulename: 'helloasso'") - Found a break in file. $matches = array(); if (preg_match('/^\s*-\s*modulename:\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) { if ($currentPackage !== null) { // Add the package to $data if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) { $data[] = $currentPackage; } } $currentPackage = ['modulename' => $matches[1]]; $currentSection = null; continue; } // If the key doesn't start with fr, en, es, it, de, treat it as a section if (!preg_match('/^\s*(fr|en|es|it|de):\s*["\']?(.*?)["\']?$/', $trimmedLine)) { $currentSection = null; } // Match a top-level key-value pair (e.g., "author: 'Dolicloud'") if (preg_match('/^(\w[\w-]*):\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) { if ($currentPackage !== null) { if ($currentSection) { // Store in the sub section (language into label or description for example) $currentPackage[$currentSection][$matches[1]] = $matches[2] === '' ? null : $matches[2]; } else { // Store as a normal key-value pair $currentPackage[$matches[1]] = $matches[2] === '' ? null : $matches[2]; } } // Match a nested section (e.g., "label:") if (preg_match('/^\s*(label|description):\s*$/', $trimmedLine, $matches)) { $currentSection = $matches[1]; $currentPackage[$currentSection] = []; // Initialize as an empty array for nested sections } continue; } } // Add the last package if available if ($currentPackage !== null) { if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) { $data[] = $currentPackage; } } return $data; } /** * Adapter data fetched from github remote source to the expected format * * @param array|list|string|null>> $data Data fetched from github remote source * @param string $source Source of the data * @return list|string|null>> Data adapted to the expected format */ public function adaptData($data, $source) { $adaptedData = []; if (!is_array($data) || empty($data) || empty($source)) { return $adaptedData; } if ($source === 'githubcommunity') { foreach ($data as $package) { if (empty($package['modulename'])) { continue; } // Check if there is a known ID $reg = array(); $id = 0; if (!empty($package['dolistore-download']) && preg_match('/www\.dolistore\.com\/product\.php\?id=(\d+)/', (string) $package['dolistore-download'], $reg)) { $id = $reg[1]; } $adaptedPackage = [ 'id' => $id, 'ref' => str_replace(' ', '', $package['modulename'] . '-' . $package['current_version'] . '@' . (array_key_exists('author', $package) ? $package['author'] : 'unkownauthor')), 'label' => !empty($package['label'][substr($this->lang, 0, 2)]) ? $package['label'][substr($this->lang, 0, 2)] : (!empty($package['label']['en']) ? $package['label']['en'] : $package['modulename']), 'description' => !empty($package['description'][substr($this->lang, 0, 2)]) ? $package['description'][substr($this->lang, 0, 2)] : (!empty($package['description']['en']) ? $package['description']['en'] : ''), 'datec' => (!empty($package['created_at']) && is_string($package['created_at'])) ? date('Y-m-d H:i:s', strtotime($package['created_at'])) : '', 'tms' => (!empty($package['last_updated_at']) && is_string($package['last_updated_at'])) ? date('Y-m-d H:i:s', strtotime($package['last_updated_at'])) : '', 'author' => array_key_exists('author', $package) ? $package['author'] : '', 'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '', 'dolibarr_min' => !empty($package['dolibarrmin']) ? $package['dolibarrmin'] : 'unknown', 'dolibarr_max' => !empty($package['dolibarrmax']) ? $package['dolibarrmax'] : 'unknown', 'phpmin' => !empty($package['phpmin']) ? $package['phpmin'] : 'unknown', 'phpmax' => !empty($package['phpmax']) ? $package['phpmax'] : 'unknown', 'module_version' => !empty($package['current_version']) ? $package['current_version'] : 'unknown', 'cover_photo_url' => !empty($package['cover']) ? $package['cover'] : '#', 'category' => (!empty($package['category']) && is_string($package['category'])) ? explode(',', str_replace(' ', '', (string) $package['category'])) : array(), 'link' => !empty($package['git']) ? $package['git'] : '#', 'source' => 'githubcommunity', 'status' => !empty($package['status']) ? $package['status'] : '', 'direct-download' => !empty($package['direct-download']) ? $package['direct-download'] : '', 'dolistore-download' => !empty($package['dolistore-download']) ? $package['dolistore-download'] : '', ]; // If a price entry exists if (array_key_exists('price', $package) && $package['price'] != null) { $adaptedPackage['price_ht'] = $package['price']; } $adaptedData[] = $adaptedPackage; } } if ($source === 'dolistore') { foreach ($data as $package) { $urlphoto = $this->shop_url.$package['cover_photo_url']; if (preg_match('/^\/?wrapper\.php\?hashp=/', $package['cover_photo_url']) && !preg_match('/attachment=/', $package['cover_photo_url'])) { $urlphoto .= '&attachment=0'; } $adaptedPackage = [ 'id' => $package['id'], 'ref' => $package['ref'], 'label' => $package['label'], 'description' => $package['description'], 'datec' => $package['datec'], 'tms' => $package['tms'], 'author' => array_key_exists('author', $package) ? $package['author'] : '', 'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '', 'price_ttc' => $package['price_ttc'], 'price_ht' => $package['price_ht'], 'dolibarr_min' => $package['dolibarr_min'], 'dolibarr_max' => $package['dolibarr_max'], 'phpmin' => empty($package['phpmin']) ? '' : $package['phpmin'], 'phpmax' => empty($package['phpmax']) ? '' : $package['phpmax'], 'module_version' => $package['module_version'], 'cover_photo_url' => $urlphoto, 'source' => 'dolistore', 'status' => empty($package['status']) ? '' : $package['status'] ]; $adaptedData[$package['id']] = $adaptedPackage; } } return $adaptedData; } /** * Apply filters to the data * @param list> $list Data to filter * @param array $options Options for the filter * * @return array{total:int, data:list>} Filtered data */ public function applyFilters($list, $options) { $filteredData = $list; // Sort products list by datec usort( $filteredData, /** * Compare creation times * @param array $a First product for comparison. * @param array $b Second product for comparison. * * @return int */ static function ($a, $b) { return strtotime($b['datec'] ?? '0') - strtotime($a['datec'] ?? '0'); } ); if (!empty($options['search'])) { $filteredData = array_filter( $filteredData, /** * Filter packages that have a label or description with the search string * * @param array $package * * @return bool */ static function ($package) use ($options) { return stripos($package['label'], $options['search']) !== false || stripos($package['description'], $options['search']) !== false; } ); } if (!empty($options['categorieid'])) { $filteredData = array_filter( $filteredData, /** * Filter the packages that belong to the filtered category * * @param array $package * * @return bool */ static function ($package) use ($options) { return in_array($options['categorieid'], $package['category']); } ); } $total = count($filteredData); // Pagination $filteredData = array_values($filteredData); $filteredData = array_slice($filteredData, ($options['page'] - 1) * $options['limit'], $options['limit']); return ['total' => $total, 'data' => $filteredData]; } /** * Check if an Dolistore API is up * * @return int */ public function checkApiStatus() { // Call remote API $testRequest = $this->callApi('categories'); if (!isset($testRequest['response']) || !is_array($testRequest['response']) || ($testRequest['status_code'] != 200 && $testRequest['status_code'] != 201)) { $this->dolistoreApiError = $this->checkStatusCode($testRequest); return 0; } else { return 1; } } /** * Retrieve the status icon * * @param mixed $status Status * @param mixed $mode Mode * @param string $moretext More text to show on tooltip * @return string */ public function libStatus($status, $mode = 3, $moretext = '') { global $langs; $statusType = 'status4'; if ($status == 0) { $statusType = 'status8'; } $labelStatus = []; $labelStatusShort = []; $labelStatus[0] = $langs->transnoentitiesnoconv("NotConnected"); $labelStatus[1] = $langs->transnoentitiesnoconv("online"); $labelStatusShort[0] = $langs->transnoentitiesnoconv("NotConnected"); $labelStatusShort[1] = $langs->transnoentitiesnoconv("online"); return dolGetStatus($labelStatus[$status], $labelStatusShort[$status], '', $statusType, $mode, '', array('badgeParams' => array('attr' => array('class' => 'classfortooltip', 'title' => $labelStatusShort[$status].$moretext)))); } /** * Download a Dolibarr module from a Git repository URL or Dolistore download URL. * * @param array $producttoinstall Product information array * @return string|false Path to the final ZIP file, or false on error */ public function getModuleZIP($producttoinstall = array()) { global $conf; // Check if cURL is available if (!function_exists('curl_init')) { dol_syslog(__METHOD__ . ': cURL is not available', LOG_ERR); return false; } // Check required fields if (empty($producttoinstall['ref'])) { dol_syslog(__METHOD__ . ': Missing producttoinstall', LOG_ERR); return false; } $current_version = $producttoinstall['module_version'] ?? ''; $module_name = strtolower(preg_replace('/@.*$/', '', $producttoinstall['ref'] ?? '')); // Remove "-" followed by current version at the end of the string if it exists $module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name); if (empty($module_name) || empty($current_version) || $current_version == 'unknown') { dol_syslog(__METHOD__ . ': Missing or unknown module name/version for product', LOG_ERR); return false; } // Create a temporary directory for the download $tmpdir = $conf->admin->dir_temp . '/remotestoredl'; dol_mkdir($tmpdir); $downloaded = false; switch ($producttoinstall['source']) { case 'dolistore': if ($producttoinstall['id'] > 0) { $source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id']; $downloaded = $this->_downloadFile($source_url, $tmpdir); if (!$downloaded) { dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR); return false; } } else { dol_syslog(__METHOD__ . ': Invalid product ID for Dolistore download: ' . $producttoinstall['id'], LOG_ERR); return false; } break; case 'githubcommunity': if ($producttoinstall['direct-download'] && in_array($producttoinstall['direct-download'], array('yes', 'dolistore'))) { $source_url = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip'; $downloaded = $this->_downloadFile($source_url, $tmpdir); if (!$downloaded) { dol_syslog(__METHOD__ . ': GitHub community module download failed: ' . $source_url . ', Try to find a Dolistore link', LOG_WARNING); if ($producttoinstall['id'] > 0) { $source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id']; $downloaded = $this->_downloadFile($source_url, $tmpdir); if (!$downloaded) { dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR); return false; } } else { dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR); return false; } } } else { dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR); return false; } break; default: dol_syslog(__METHOD__ . ': Unsupported source type: ' . $producttoinstall['source'], LOG_ERR); } dol_syslog(__METHOD__ . ': Module downloaded successfully to: ' . $downloaded, LOG_DEBUG); return $downloaded; } /** * Download a remote URL to a local file using getURLContent (native Dolibarr). * * @param string $url Remote URL to download * @param string $dest_path Local path to write the downloaded file (directory, not including filename) * @return string|false Full path of downloaded file on success, false on failure */ private function _downloadFile(string $url, string $dest_path) { // HEAD request to get real filename from Content-Disposition $filename = ''; $head = getURLContent($url, 'HEAD'); // Try to extract filename from Content-Disposition header if (!empty($head['header'])) { if (preg_match_all('/Content-Disposition:.*filename=["\']?([^"\';\r\n]+)/i', $head['header'], $m)) { $filename = trim(end($m[1]), " \t\"'"); } } // If filename is not found in headers, try to extract it from URL if (empty($filename)) { $filename = basename(parse_url($url, PHP_URL_PATH)); } // If filename is still empty or file name ne match the expected pattern (module_modulename-version.zip), log error and return false if (empty($filename) || !preg_match('/^module_[a-z0-9_]+-[0-9]+\.[0-9]+\.[0-9]+\.zip$/i', $filename)) { dol_syslog(__METHOD__ . ': Cannot determine filename from URL: ' . $url, LOG_ERR); return false; } // Download the file $response = getURLContent($url, 'GET'); if (empty($response['content']) || (isset($response['http_code']) && $response['http_code'] !== 200)) { dol_syslog( __METHOD__ . ': Download failed — HTTP ' . ($response['http_code'] ?? 'unknown') . ' — ' . $url, LOG_WARNING ); return false; } // Write to destination $dest_file = $dest_path . '/' . $filename; if (file_exists(dol_osencode($dest_file))) { // If file already exists, try to delete it first chmod(dol_osencode($dest_file), 0755); @unlink(dol_osencode($dest_file)); } $writtenfile = file_put_contents(dol_osencode($dest_file), $response['content']); if ($writtenfile === false || $writtenfile === 0) { dol_syslog(__METHOD__ . ': Cannot write file: ' . $dest_file, LOG_ERR); @unlink(dol_osencode($dest_file)); return false; } dol_syslog(__METHOD__ . ': Downloaded successfully to: ' . $dest_file, LOG_DEBUG); return $dest_file; } }