dolibarr/htdocs/admin/remotestore/class/externalModules.class.php
Laurent Destailleur 2c74444b1d Clean code
2026-08-16 06:12:35 +02:00

1493 lines
51 KiB
PHP

<?php
/*
* Copyright (C) 2025 Mohamed DAOUD <mdaoud@dolicloud.com>
* Copyright (C) 2025-2026 MDW <mdeweerd@users.noreply.github.com>
* Copyright (C) 2025 Frédéric France <frederic.france@free.fr>
*
* 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 <https://www.gnu.org/licenses/>.
* 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<int, mixed>|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<string, mixed>|false $options Options for the request
* @return array{status_code:int,response:null|string|array<string,mixed>}
*/
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<string, mixed> $options Options for filter
*
* @return list<array<string, array<string, string|null>|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 .= '<li' . ($current == $value['rowid'] ? ' class="active"' : '') . '>';
$html .= '<a href="?mode=marketplace&categorie=' . $value['rowid'] . '">' . $value['label'] . '</a>';
if (isset($value['children'])) {
$html .= '<ul>';
usort($value['children'], $this->buildSorter('position'));
foreach ($value['children'] as $key_children => $value_children) {
$html .= '<li' . ($current == $value_children['rowid'] ? ' class="active"' : '') . '>';
$html .= '<a href="?mode=marketplace&categorie=' . $value_children['rowid'] . '" title="' . dol_escape_htmltag(strip_tags($value_children['description'])) . '">' . $value_children['label'] . '</a>';
$html .= '</li>';
}
$html .= '</ul>';
}
$html .= '</li>';
}
}
return $html;
}
/**
* Generate HTML for products.
*
* @param array<string,mixed> $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 .= '<tr class=""><td colspan="3" class="center">';
$html .= '<br><br>';
$html .= $langs->trans("SearchStringMinLength").'...';
$html .= '<br><br>';
$html .= '</td></tr>';
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 = '<div class="shop-container">
<div class="shop-image">
<a href="https://merch.dolibarr.org/" target="_blank">
<img src="https://www.dolistore.com/medias/image/marketplace/img/goodies-shop.jpg" width="50%" alt="DoliStore Merch and Gifts" />
<div class="shop-overlay">
<button target="new" class="shop-button">'.$langs->trans("GoodiesButtonTitle").' <i class="icon-chevron-right"></i></button>
</div>
</a>
</div>
</div>';
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 .= '<span class="newApp" title="'.$product['tms'].'">'.$langs->trans('New').'</span> ';
}
// check updated ?
if ($newapp == '' && $last_month < strtotime($product['tms']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') {
$newapp .= '<span class="updatedApp" title="'.$product['tms'].'">'.$langs->trans('UpdatedRecently').'</span> ';
}
// add image or default ?
if ($product["cover_photo_url"] != '' && $product["cover_photo_url"] != '#') {
$images = '<a href="'.$product["cover_photo_url"].'" class="documentpreview" target="_blank" rel="noopener noreferrer" mime="image/png" title="'.dol_escape_htmltag($product["label"].', '.$langs->trans('Version').' '.$product["module_version"]).'">';
$images .= '<img class="imgstore" src="'.$product["cover_photo_url"].'" alt="" /></a>';
} else {
$images = '<img class="imgstore" src="'.DOL_URL_ROOT.'/public/theme/common/nophoto.png" />';
}
// Set and check version
$version = '';
$compatible = '';
if ($product["status"] == 'soon' || $product["status"] == 'development' || $product["status"] == 'experimental') {
$version = '<span class="warning">'.$langs->trans("NotYetAvailable").' - '.$langs->trans("StillInDevelopment").'</span>';
$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 = '<span class="compatible hideonsmartphone">'.$langs->trans(
'CompatibleUpTo',
$dolibarrversiontouse,
$product["dolibarr_min"],
$product["dolibarr_max"]
).'</span>';
$compatible = '';
} else {
// Never compatible, module expired
$version = '<span class="warning">'.$langs->trans(
'NotCompatible',
$dolibarrversiontouse,
$product["dolibarr_min"],
$product["dolibarr_max"]
).'</span>';
$compatible = 'NotCompatible';
}
} else {
if ($product["dolibarr_min"] == 'auto' || $product["dolibarr_min"] != 'unknown') {
// Never compatible, module expired
$version = '<span class="warning">'.$langs->trans(
'NotCompatible',
$dolibarrversiontouse,
$product["dolibarr_min"],
$product["dolibarr_max"]
).'</span>';
$compatible = 'NotCompatible';
} else {
// Need update
$version = '<span class="compatibleafterupdate">'.$langs->trans(
'CompatibleAfterUpdate',
$dolibarrversiontouse,
$product["dolibarr_min"],
$product["dolibarr_max"]
).'</span>';
$compatible = 'NotCompatible';
}
}
// free or pay ?
$install_link = '';
if (array_key_exists('price_ht', $product) && price2num($product["price_ht"]) > 0) {
$price = '<h3>'.price(price2num($product["price_ht"], 'MT'), 0, $langs, 1, -1, -1, 'EUR').' '.$langs->trans("HT").'</h3>';
$download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$this->shop_url.'/product.php?id='.((int) $product['id']).'">';
$download_link .= img_picto('', 'url', 'class="size2x paddingright"');
$download_link .= '</a>';
} 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 = '<h3><a href="'.$urlview.'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
} elseif ($product['source'] === 'githubcommunity') {
if (array_key_exists('price_ht', $product) && empty($product['price_ht'])) {
if ($product['status'] == 'soon') {
$price = '<h3>'.$langs->trans('StillInDevelopment').'</h3>';
} else {
$price = '<h3>'.$langs->trans('Free').'</h3>';
}
} else {
if ($product["dolistore-download"]) {
$price = '<h3><a href="'.$product["dolistore-download"].'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
} else {
$price = '<h3>'.$langs->trans('Unknown').'</h3>';
}
}
} else {
$price = '<h3>'.$langs->trans('Unknown').'</h3>';
}
if ($product['source'] === 'githubcommunity') {
$download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Sources").'" href="'.$product["link"].'">';
$download_link .= img_picto('', 'file-code', 'class="size2x paddingright colorgrey"');
$download_link .= '</a>';
$urlview = $product["dolistore-download"]; // View on Dolistore
if ($urlview) {
$download_link .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'" rel="noopener noreferrer">';
$download_link .= img_picto('', 'url', 'class="size2x"');
$download_link .= '</a>';
}
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 .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
$download_link .= img_picto('', 'download', 'class="size2x paddingright"');
$download_link .= '</a>';
}
}
} 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 = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'">';
$download_link .= img_picto('', 'url', 'class="size2x"');
$download_link .= '</a>';
$download_link .= '<a class="paddingleft paddingright" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
$download_link .= img_picto('', 'download', 'class="size2x paddingright"');
$download_link .= '</a>';
}
// 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 = '<button class="valignmiddle ' . ($disableInstall ? 'butActionRefused' : 'butAction') . ' paddingleft paddingright"'
. ($disableInfo ? ' title="' . dol_escape_htmltag($disableInfo) . '"' : '')
. (!$disableInstall ? ' data-confirm' : '')
. (!$disableInstall ? ' data-fields="' . dol_escape_htmltag(json_encode($fields)) . '"' : '')
. (!$disableInstall ? ' data-url="' . dol_escape_htmltag($this->url) . '"' : '')
. (!$disableInstall ? ' data-confirm-title="' . dol_escape_htmltag($langs->trans("extModuleConfirmInstallTitle")) . '"' : '')
. (!$disableInstall ? ' data-confirm-text="' . dol_escape_htmltag($installConfirmMessage) . '"' : '')
. '>' . $langs->trans("Install") . '</button>';
}
}
// Output the line
$html .= '<tr class="'.(getDolOptimizeSmallScreen() ? 'app' : 'app app2').' oddeven nohover '.dol_escape_htmltag($compatible).'">';
// Logo
$html .= '<td class="center width150"><div class="newAppParent">';
$html .= $newapp.$images; // No dol_escape_htmltag, it is already escape html
$html .= '</div></td>';
// Description
$html .= '<td class="margeCote minwidth400imp"><h2 class="appTitle">';
$html .= dolPrintHTML(dol_string_nohtmltag(ucfirst($product["label"])));
if (!empty($product['author']) && $product['author'] != 'unkownauthor') {
$html .= '<span class="small"> &nbsp; - &nbsp; '.img_picto('', 'company', 'class="pictofixedwidth"');
if (!empty($product['author_url'])) {
$html .= '<a href="'.$product['author_url'].'" target="_blank">'.$product['author'].'</a>';
} else {
$html .= $product['author'];
}
$html .= '</span>';
}
$html .= '<br><span class="small">';
$html .= $version; // Version Dolibarr. No dol_escape_htmltag, it is already escape html
$html .= '</span>';
$html .= '</h2>';
$html .= '<small class="appDateCreation appRef"> ';
if (empty($product['tms'])) {
$html .= img_picto($langs->trans('DateCreation'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium"><span class="hideonsmartphone">'.$langs->trans("DateCreation").': </span>';
$html .= (!empty($product['datec']) ? dol_print_date(dol_stringtotime($product['datec']), 'day') : $langs->trans("Unknown")).'</span>';
} else {
$html .= img_picto($langs->trans('DateModification'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium">'.dol_print_date(dol_stringtotime($product['tms']), 'day').'</span>';
}
$html .= ' &nbsp; &nbsp; ';
$html .= '<div class="appSource inline-block valigntop">';
if ($product["source"] == 'dolistore') {
$html .= '<img border="0" title="'.dolPrintHTML($langs->trans('Source').": DoliStore").'" class="imgautosize valignmiddle inline-block pictofixedwidth" style="height: 14px" src="'.DOL_URL_ROOT.'/theme/dolistore_squarred.svg">';
} 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 .= '</div>';
$html .= $langs->trans('Ref').' '.dolPrintHTML(preg_replace('/@.*$/', '', $product["ref"]));
$html .= '</small><br>';
$html .= '&nbsp;';
if (!empty($product['phpmin']) && $product['phpmin'] != 'unknown') {
$html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP min '.$product['phpmin'].'</span>';
}
if (!empty($product['phpmax']) && $product['phpmax'] != 'unknown') {
$html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP max '.$product['phpmax'].'</span>';
}
$html .= '<br>';
$html .= '<br>';
$html .= '<div class="storedesc">'.dolPrintHTML(dol_string_nohtmltag($product["description"])).'</div>';
$html .= '</td>';
if (getDolOptimizeSmallScreen()) {
$html .= '</tr><tr class="app2 oddeven nohover borderbottom '.dol_escape_htmltag($compatible).'">';
}
// Price - do not load if display none
$html .= '<td class="margeCote center amount'.(getDolOptimizeSmallScreen() ? ' left" colspan="2"' : '"').'>';
$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 .= '</td>';
$html .= '<td class="margeCote nowraponall">';
}
// Links
$html .= $download_link;
$html .= '</td>';
$html .= '</tr>';
}
if (empty($this->products)) {
$colspan = (getDolOptimizeSmallScreen() ? 1 : 3);
$langs->load("website");
$html .= '<tr class=""><td colspan="'.$colspan.'" class="center">';
$html .= '<br><br>';
$html .= $langs->trans("noResultsWereFound").'...';
$html .= '<br><br>';
$html .= '</td></tr>';
}
// JS for confirm install
$confirmLabel = dol_escape_js($langs->trans("Install"));
$cancelLabel = dol_escape_js($langs->trans("Cancel"));
$html .= '<script>
$(document).on("click","[data-confirm]",function(){
var button = $(this);
var confirmTitle = button.data("confirm-title");
var confirmText = button.data("confirm-text");
var buttons = {};
buttons[button.data("confirm-label")||"' . $confirmLabel . '"] = function(){
var form = $("<form method=\'POST\' style=\'display:none\'>").attr("action", button.data("url"));
$.each(button.data("fields"), function(name, value){
form.append($("<input type=\'hidden\'>").attr("name", name).val(value));
});
$("body").append(form);
form.submit();
$(this).dialog("close");
};
buttons["' . $cancelLabel . '"] = function(){$(this).dialog("close");};
$("<div>").html(confirmText).dialog({
title: confirmTitle,
minWidth: 580,
modal: true,
buttons: buttons
});
});
</script>';
$this->numberOfProducts = count($this->products);
return $html;
}
/**
* Sort an array by a key
*
* @param string $key Key to sort by
* @return Closure(array<string, mixed>, array<string, mixed>): int
*/
public function buildSorter(string $key): Closure
{
return
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $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.'<br>'."\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 '<a href="'.$this->get_previous_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
}
// 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 '<a href="'.$this->get_next_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
}
// 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 .= '<li class="pagination paginationpage paginationpageleft"><a class="paginationprevious reposition" href="'.$this->get_previous_url().'"><i class="fa fa-chevron-left" title="'.dol_escape_htmltag($langs->trans("Previous")).'"></i></a></li>';
}
$pagelist .= '<li class="pagination">';
$pagelist .= '<label for="page_input">Page </label>';
if ($this->categorie != 0) {
$pagelist .= '<input type="hidden" name="categorie" value="' . $this->categorie . '">';
}
$pagelist .= '<input type="text" id="page_input" name="no_page" value="'.($page).'" min="1" max="'.$nbpages.'" class="width40 page_input right" oninput="if(this.value > '.$nbpages.') this.value='.$nbpages.'">';
$pagelist .= ' / '.$nbpages;
$pagelist .= '</li>';
// Show next page
if ($page < $nbpages) {
$pagelist .= '<li class="pagination paginationpage paginationpageright"><a class="paginationnext reposition" href="'.$this->get_next_url().'"><i class="fa fa-chevron-right" title="'.dol_escape_htmltag($langs->trans("Next")).'"></i></a></li>';
}
}
}
if ($limit || $pagelist) {
$html .= '<div class="pagination" style="padding: 7px;">';
$html .= '<ul>';
$html .= $pagelist;
$html .= '</ul>';
$html .= '</div>';
}
$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<array<string, array<string, string|null>|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<string, mixed>|list<array<string, array<string, string|null>|string|null>> $data Data fetched from github remote source
* @param string $source Source of the data
* @return list<array<string, array<string, string|null>|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<array<string, mixed>> $list Data to filter
* @param array<string, mixed> $options Options for the filter
*
* @return array{total:int, data:list<array<string, mixed>>} Filtered data
*/
public function applyFilters($list, $options)
{
$filteredData = $list;
// Sort products list by datec
usort(
$filteredData,
/**
* Compare creation times
* @param array<string, mixed> $a First product for comparison.
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}