mirror of
https://github.com/the-djmaze/snappymail.git
synced 2025-01-01 04:22:15 +08:00
Improvements for issue #136
This commit is contained in:
parent
9cf83eb62a
commit
3756264669
7 changed files with 175 additions and 151 deletions
|
@ -57,13 +57,6 @@ export class PackagesAdminSettings {
|
|||
|
||||
requestHelper(packageToRequest, install) {
|
||||
return (iError, data) => {
|
||||
if (iError) {
|
||||
this.packagesError(
|
||||
getNotification(install ? Notification.CantInstallPackage : Notification.CantDeletePackage)
|
||||
// ':\n' + getNotification(iError);
|
||||
);
|
||||
}
|
||||
|
||||
PackageAdminStore.forEach(item => {
|
||||
if (item && packageToRequest && item.loading && item.loading() && packageToRequest.file === item.file) {
|
||||
packageToRequest.loading(false);
|
||||
|
@ -71,7 +64,12 @@ export class PackagesAdminSettings {
|
|||
}
|
||||
});
|
||||
|
||||
if (!iError && data.Result.Reload) {
|
||||
if (iError) {
|
||||
this.packagesError(
|
||||
getNotification(install ? Notification.CantInstallPackage : Notification.CantDeletePackage)
|
||||
+ (data.ErrorMessage ? ':\n' + data.ErrorMessage : '')
|
||||
);
|
||||
} else if (data.Result.Reload) {
|
||||
location.reload();
|
||||
} else {
|
||||
PackageAdminStore.fetch();
|
||||
|
|
|
@ -8,7 +8,7 @@ PackageAdminStore.real = ko.observable(true);
|
|||
|
||||
PackageAdminStore.loading = ko.observable(false);
|
||||
|
||||
//PackageAdminStore.error = ko.observable('');
|
||||
PackageAdminStore.error = ko.observable('');
|
||||
|
||||
PackageAdminStore.fetch = () => {
|
||||
PackageAdminStore.loading(true);
|
||||
|
@ -18,6 +18,7 @@ PackageAdminStore.fetch = () => {
|
|||
PackageAdminStore.real(false);
|
||||
} else {
|
||||
PackageAdminStore.real(!!data.Result.Real);
|
||||
PackageAdminStore.error(data.Result.Error);
|
||||
|
||||
const loading = {};
|
||||
PackageAdminStore.forEach(item => {
|
||||
|
|
|
@ -181,7 +181,7 @@ trait Admin
|
|||
|
||||
$totp = $this->Config()->Get('security', 'admin_totp', '');
|
||||
|
||||
if (0 === strlen($sLogin) || 0 === strlen($sPassword) ||
|
||||
if (!\strlen($sLogin) || !\strlen($sPassword) ||
|
||||
!$this->Config()->Get('security', 'allow_admin_panel', true) ||
|
||||
$sLogin !== $this->Config()->Get('security', 'admin_login', '') ||
|
||||
!$this->Config()->ValidatePassword($sPassword)
|
||||
|
@ -245,7 +245,7 @@ trait Admin
|
|||
$this->Logger()->AddSecret($sPassword);
|
||||
|
||||
$sNewPassword = $this->GetActionParam('NewPassword', '');
|
||||
if (0 < \strlen(\trim($sNewPassword)))
|
||||
if (\strlen(\trim($sNewPassword)))
|
||||
{
|
||||
$this->Logger()->AddSecret($sNewPassword);
|
||||
}
|
||||
|
@ -254,12 +254,12 @@ trait Admin
|
|||
|
||||
if ($oConfig->ValidatePassword($sPassword))
|
||||
{
|
||||
if (0 < \strlen($sLogin))
|
||||
if (\strlen($sLogin))
|
||||
{
|
||||
$oConfig->Set('security', 'admin_login', $sLogin);
|
||||
}
|
||||
|
||||
if (0 < \strlen(\trim($sNewPassword)))
|
||||
if (\strlen(\trim($sNewPassword)))
|
||||
{
|
||||
$oConfig->SetPassword($sNewPassword);
|
||||
if (\is_file($passfile) && \trim(\file_get_contents($passfile)) !== $sNewPassword) {
|
||||
|
@ -485,16 +485,7 @@ trait Admin
|
|||
return 'https://snappymail.eu/repository/v2/';
|
||||
}
|
||||
|
||||
private function rainLoopUpdatable() : bool
|
||||
{
|
||||
return \file_exists(APP_INDEX_ROOT_PATH.'index.php') &&
|
||||
\is_writable(APP_INDEX_ROOT_PATH.'index.php') &&
|
||||
\is_writable(APP_INDEX_ROOT_PATH.'snappymail/') &&
|
||||
APP_VERSION !== APP_DEV_VERSION
|
||||
;
|
||||
}
|
||||
|
||||
private function getRepositoryDataByUrl(string $sRepo, bool &$bReal = false) : array
|
||||
private function getRepositoryDataByUrl(bool &$bReal = false) : array
|
||||
{
|
||||
$bReal = false;
|
||||
$aRep = null;
|
||||
|
@ -503,9 +494,7 @@ trait Admin
|
|||
$sRepoFile = 'packages.json';
|
||||
$iRepTime = 0;
|
||||
|
||||
$oHttp = \MailSo\Base\Http::SingletonInstance();
|
||||
|
||||
$sCacheKey = KeyPathHelper::RepositoryCacheFile($sRepo, $sRepoFile);
|
||||
$sCacheKey = KeyPathHelper::RepositoryCacheFile(\SnappyMail\Repository::BASE_URL, $sRepoFile);
|
||||
$sRep = $this->Cacher()->Get($sCacheKey);
|
||||
if ('' !== $sRep)
|
||||
{
|
||||
|
@ -514,14 +503,11 @@ trait Admin
|
|||
|
||||
if ('' === $sRep || 0 === $iRepTime || \time() - 3600 > $iRepTime)
|
||||
{
|
||||
$iCode = 0;
|
||||
$sContentType = '';
|
||||
|
||||
$sRepPath = $sRepo.$sRepoFile;
|
||||
$sRep = '' !== $sRepo ? $oHttp->GetUrlAsString($sRepPath, 'SnappyMail', $sContentType, $iCode, $this->Logger(), 10,
|
||||
$this->Config()->Get('labs', 'curl_proxy', ''), $this->Config()->Get('labs', 'curl_proxy_auth', '')) : false;
|
||||
|
||||
if (false !== $sRep)
|
||||
$sRep = \SnappyMail\Repository::get($sRepoFile,
|
||||
$this->Config()->Get('labs', 'curl_proxy', ''),
|
||||
$this->Config()->Get('labs', 'curl_proxy_auth', '')
|
||||
);
|
||||
if ($sRep)
|
||||
{
|
||||
$aRep = \json_decode($sRep);
|
||||
$bReal = \is_array($aRep) && 0 < \count($aRep);
|
||||
|
@ -534,7 +520,7 @@ trait Admin
|
|||
}
|
||||
else
|
||||
{
|
||||
$this->Logger()->Write('Cannot read remote repository file: '.$sRepPath, \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
throw new \Exception('Cannot read remote repository file: '.$sRepoFile);
|
||||
}
|
||||
}
|
||||
else if ('' !== $sRep)
|
||||
|
@ -546,39 +532,42 @@ trait Admin
|
|||
return \is_array($aRep) ? $aRep : [];
|
||||
}
|
||||
|
||||
private function getRepositoryData(bool &$bReal, bool &$bRainLoopUpdatable) : array
|
||||
private function getRepositoryData(bool &$bReal, string &$sError) : array
|
||||
{
|
||||
$bRainLoopUpdatable = $this->rainLoopUpdatable();
|
||||
|
||||
$aResult = array();
|
||||
foreach ($this->getRepositoryDataByUrl($this->snappyMailRepo(), $bReal) as $oItem) {
|
||||
if ($oItem && isset($oItem->type, $oItem->id, $oItem->name,
|
||||
$oItem->version, $oItem->release, $oItem->file, $oItem->description))
|
||||
{
|
||||
if (!empty($oItem->required) && APP_DEV_VERSION !== APP_VERSION && version_compare(APP_VERSION, $oItem->required, '<')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
foreach ($this->getRepositoryDataByUrl($bReal) as $oItem) {
|
||||
if ($oItem && isset($oItem->type, $oItem->id, $oItem->name,
|
||||
$oItem->version, $oItem->release, $oItem->file, $oItem->description))
|
||||
{
|
||||
if (!empty($oItem->required) && APP_DEV_VERSION !== APP_VERSION && version_compare(APP_VERSION, $oItem->required, '<')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($oItem->depricated) && APP_DEV_VERSION !== APP_VERSION && version_compare(APP_VERSION, $oItem->depricated, '>=')) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($oItem->depricated) && APP_DEV_VERSION !== APP_VERSION && version_compare(APP_VERSION, $oItem->depricated, '>=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('plugin' === $oItem->type) {
|
||||
$aResult[$oItem->id] = array(
|
||||
'type' => $oItem->type,
|
||||
'id' => $oItem->id,
|
||||
'name' => $oItem->name,
|
||||
'installed' => '',
|
||||
'enabled' => true,
|
||||
'version' => $oItem->version,
|
||||
'file' => $oItem->file,
|
||||
'release' => $oItem->release,
|
||||
'desc' => $oItem->description,
|
||||
'canBeDeleted' => false,
|
||||
'canBeUpdated' => true
|
||||
);
|
||||
if ('plugin' === $oItem->type) {
|
||||
$aResult[$oItem->id] = array(
|
||||
'type' => $oItem->type,
|
||||
'id' => $oItem->id,
|
||||
'name' => $oItem->name,
|
||||
'installed' => '',
|
||||
'enabled' => true,
|
||||
'version' => $oItem->version,
|
||||
'file' => $oItem->file,
|
||||
'release' => $oItem->release,
|
||||
'desc' => $oItem->description,
|
||||
'canBeDeleted' => false,
|
||||
'canBeUpdated' => true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$sError = "{$e->getCode()} {$e->getMessage()}";
|
||||
$this->Logger()->Write($sError, \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
}
|
||||
|
||||
$aEnabledPlugins = \array_map('trim',
|
||||
|
@ -619,15 +608,21 @@ trait Admin
|
|||
$this->IsAdminLoggined();
|
||||
|
||||
$bReal = false;
|
||||
$bRainLoopUpdatable = false;
|
||||
$aList = $this->getRepositoryData($bReal, $bRainLoopUpdatable);
|
||||
$sError = '';
|
||||
$aList = $this->getRepositoryData($bReal, $sError);
|
||||
|
||||
$bRainLoopUpdatable = \file_exists(APP_INDEX_ROOT_PATH.'index.php')
|
||||
&& \is_writable(APP_INDEX_ROOT_PATH.'index.php')
|
||||
&& \is_writable(APP_INDEX_ROOT_PATH.'snappymail/')
|
||||
&& APP_VERSION !== APP_DEV_VERSION;
|
||||
|
||||
// \uksort($aList, function($a, $b){return \strcasecmp($a['name'], $b['name']);});
|
||||
|
||||
return $this->DefaultResponse(__FUNCTION__, array(
|
||||
'Real' => $bReal,
|
||||
'MainUpdatable' => $bRainLoopUpdatable,
|
||||
'List' => $aList
|
||||
'List' => $aList,
|
||||
'Error' => $sError
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -637,24 +632,8 @@ trait Admin
|
|||
|
||||
$sId = $this->GetActionParam('Id', '');
|
||||
|
||||
$bReal = false;
|
||||
$bRainLoopUpdatable = false;
|
||||
$aList = $this->getRepositoryData($bReal, $bRainLoopUpdatable);
|
||||
|
||||
$sResultId = '';
|
||||
foreach ($aList as $oItem)
|
||||
{
|
||||
if ($oItem && 'plugin' === $oItem['type'] && $sId === $oItem['id'])
|
||||
{
|
||||
$sResultId = $sId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$bResult = '' !== $sResultId && static::deletePackageDir($sResultId);
|
||||
if ($bResult) {
|
||||
$this->pluginEnable($sResultId, false);
|
||||
}
|
||||
$bResult = static::deletePackageDir($sId);
|
||||
$this->pluginEnable($sId, false);
|
||||
|
||||
return $this->DefaultResponse(__FUNCTION__, $bResult);
|
||||
}
|
||||
|
@ -666,38 +645,6 @@ trait Admin
|
|||
&& (!\is_file("{$sPath}.phar") || \unlink("{$sPath}.phar"));
|
||||
}
|
||||
|
||||
private function downloadRemotePackageByUrl(string $sUrl) : string
|
||||
{
|
||||
$bResult = false;
|
||||
$sTmp = APP_PRIVATE_DATA.\md5(\microtime(true).$sUrl) . \substr($sUrl, -4);
|
||||
$pDest = \fopen($sTmp, 'w+b');
|
||||
if ($pDest)
|
||||
{
|
||||
$iCode = 0;
|
||||
$sContentType = '';
|
||||
|
||||
\set_time_limit(120);
|
||||
|
||||
$oHttp = \MailSo\Base\Http::SingletonInstance();
|
||||
$bResult = $oHttp->SaveUrlToFile($sUrl, $pDest, $sTmp, $sContentType, $iCode, $this->Logger(), 60,
|
||||
$this->Config()->Get('labs', 'curl_proxy', ''), $this->Config()->Get('labs', 'curl_proxy_auth', ''));
|
||||
|
||||
if (!$bResult)
|
||||
{
|
||||
$this->Logger()->Write('Cannot save url to temp file: ', \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
$this->Logger()->Write($sUrl.' -> '.$sTmp, \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
}
|
||||
|
||||
\fclose($pDest);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->Logger()->Write('Cannot create temp file: '.$sTmp, \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
}
|
||||
|
||||
return $bResult ? $sTmp : '';
|
||||
}
|
||||
|
||||
public function DoAdminPackageInstall() : array
|
||||
{
|
||||
$this->IsAdminLoggined();
|
||||
|
@ -710,40 +657,48 @@ trait Admin
|
|||
|
||||
$sRealFile = '';
|
||||
|
||||
$bReal = false;
|
||||
$bRainLoopUpdatable = false;
|
||||
$aList = $this->getRepositoryData($bReal, $bRainLoopUpdatable);
|
||||
|
||||
if ('plugin' === $sType)
|
||||
{
|
||||
foreach ($aList as $oItem)
|
||||
{
|
||||
if ($oItem && $sFile === $oItem['file'] && $sId === $oItem['id'])
|
||||
{
|
||||
$sRealFile = $sFile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bResult = false;
|
||||
$sTmp = $sRealFile ? $this->downloadRemotePackageByUrl($this->snappyMailRepo().$sRealFile) : null;
|
||||
if ($sTmp)
|
||||
{
|
||||
$oArchive = new \PharData($sTmp, 0, $sRealFile);
|
||||
if (static::deletePackageDir($sId)) {
|
||||
if ('.phar' === \substr($sRealFile, -5)) {
|
||||
$bResult = \copy($sTmp, APP_PLUGINS_PATH . \basename($sRealFile));
|
||||
} else {
|
||||
$bResult = $oArchive->extractTo(APP_PLUGINS_PATH);
|
||||
$sTmp = null;
|
||||
try {
|
||||
if ('plugin' === $sType) {
|
||||
$bReal = false;
|
||||
$sError = '';
|
||||
$aList = $this->getRepositoryData($bReal, $sError);
|
||||
if ($sError) {
|
||||
throw new \Exception($sError);
|
||||
}
|
||||
if (!$bResult) {
|
||||
$this->Logger()->Write('Cannot extract package files: '.$oArchive->getStatusString(), \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
foreach ($aList as $oItem) {
|
||||
if ($oItem && $sFile === $oItem['file'] && $sId === $oItem['id']) {
|
||||
$sRealFile = $sFile;
|
||||
$sTmp = \SnappyMail\Repository::download($sFile,
|
||||
$this->Config()->Get('labs', 'curl_proxy', ''),
|
||||
$this->Config()->Get('labs', 'curl_proxy_auth', '')
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->Logger()->Write('Cannot remove previous plugin folder: '.$sId, \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
}
|
||||
\unlink($sTmp);
|
||||
|
||||
if ($sTmp) {
|
||||
$oArchive = new \PharData($sTmp, 0, $sRealFile);
|
||||
if (static::deletePackageDir($sId)) {
|
||||
if ('.phar' === \substr($sRealFile, -5)) {
|
||||
$bResult = \copy($sTmp, APP_PLUGINS_PATH . \basename($sRealFile));
|
||||
} else {
|
||||
$bResult = $oArchive->extractTo(\rtrim(APP_PLUGINS_PATH, '\\/'));
|
||||
}
|
||||
if (!$bResult) {
|
||||
throw new \Exception('Cannot extract package files: '.$oArchive->getStatusString());
|
||||
}
|
||||
} else {
|
||||
throw new \Exception('Cannot remove previous plugin folder: '.$sId);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->Logger()->Write("Install package {$sRealFile} failed: {$e->getMessage()}", \MailSo\Log\Enumerations\Type::ERROR, 'INSTALLER');
|
||||
throw $e;
|
||||
} finally {
|
||||
$sTmp && \unlink($sTmp);
|
||||
}
|
||||
|
||||
return $this->DefaultResponse(__FUNCTION__, $bResult ?
|
||||
|
@ -752,7 +707,7 @@ trait Admin
|
|||
|
||||
private function pluginEnable(string $sName, bool $bEnable = true) : bool
|
||||
{
|
||||
if (0 === \strlen($sName))
|
||||
if (!\strlen($sName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
@ -773,7 +728,7 @@ trait Admin
|
|||
{
|
||||
foreach ($aEnabledPlugins as $sPlugin)
|
||||
{
|
||||
if ($sName !== $sPlugin && 0 < \strlen($sPlugin))
|
||||
if ($sName !== $sPlugin && \strlen($sPlugin))
|
||||
{
|
||||
$aNewEnabledPlugins[] = $sPlugin;
|
||||
}
|
||||
|
@ -798,7 +753,7 @@ trait Admin
|
|||
if ($oPlugin)
|
||||
{
|
||||
$sValue = $oPlugin->Supported();
|
||||
if (0 < \strlen($sValue))
|
||||
if (\strlen($sValue))
|
||||
{
|
||||
return $this->FalseResponse(__FUNCTION__, Notifications::UnsupportedPluginPackage, $sValue);
|
||||
}
|
||||
|
|
|
@ -17,7 +17,8 @@ abstract class Request
|
|||
$user_agent,
|
||||
$max_redirects = 0,
|
||||
$verify_peer = false,
|
||||
$proxy = null;
|
||||
$proxy = null,
|
||||
$proxy_auth = null;
|
||||
|
||||
protected
|
||||
$auth = [
|
||||
|
|
|
@ -63,6 +63,9 @@ class CURL extends \SnappyMail\HTTP\Request
|
|||
}
|
||||
if ($this->proxy) {
|
||||
\curl_setopt($c, CURLOPT_PROXY, $this->proxy);
|
||||
if ($this->proxy_auth) {
|
||||
\curl_setopt($c, CURLOPT_PROXYUSERPWD, $this->proxy_auth);
|
||||
}
|
||||
}
|
||||
if ('HEAD' === $method) {
|
||||
\curl_setopt($c, CURLOPT_NOBODY, true);
|
||||
|
|
64
snappymail/v/0.0.0/app/libraries/snappymail/repository.php
Normal file
64
snappymail/v/0.0.0/app/libraries/snappymail/repository.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace SnappyMail;
|
||||
|
||||
abstract class Repository
|
||||
{
|
||||
// snappyMailRepo
|
||||
const BASE_URL = 'https://snappymail.eu/repository/v2/';
|
||||
|
||||
public static function get(string $path, string $proxy, string $proxy_auth) : string
|
||||
{
|
||||
$oHTTP = HTTP\Request::factory(/*'socket' or 'curl'*/);
|
||||
$oHTTP->proxy = $proxy;
|
||||
$oHTTP->proxy_auth = $proxy_auth;
|
||||
$oHTTP->max_response_kb = 0;
|
||||
$oHTTP->timeout = 15; // timeout in seconds.
|
||||
$oResponse = $oHTTP->doRequest('GET', static::BASE_URL . $path);
|
||||
if (!$oResponse) {
|
||||
throw new \Exception('No HTTP response from repository');
|
||||
}
|
||||
if (200 !== $oResponse->status) {
|
||||
throw new \Exception(static::body2plain($oResponse->body), $oResponse->status);
|
||||
}
|
||||
return $oResponse->body;
|
||||
}
|
||||
|
||||
// $aRep = \json_decode($sRep);
|
||||
|
||||
public static function download(string $path, string $proxy, string $proxy_auth) : string
|
||||
{
|
||||
$sTmp = APP_PRIVATE_DATA . \md5(\microtime(true).$path) . \preg_replace('/^.*?(\\.[a-z\\.]+)$/Di', '$1', $path);
|
||||
$pDest = \fopen($sTmp, 'w+b');
|
||||
if (!$pDest) {
|
||||
throw new \Exception('Cannot create temp file: '.$sTmp);
|
||||
}
|
||||
$oHTTP = HTTP\Request::factory(/*'socket' or 'curl'*/);
|
||||
$oHTTP->proxy = $proxy;
|
||||
$oHTTP->proxy_auth = $proxy_auth;
|
||||
$oHTTP->max_response_kb = 0;
|
||||
$oHTTP->timeout = 15; // timeout in seconds.
|
||||
$oHTTP->streamBodyTo($pDest);
|
||||
\set_time_limit(120);
|
||||
$oResponse = $oHTTP->doRequest('GET', static::BASE_URL . $path);
|
||||
\fclose($pDest);
|
||||
if (!$oResponse) {
|
||||
\unlink($sTmp);
|
||||
throw new \Exception('No HTTP response from repository');
|
||||
}
|
||||
if (200 !== $oResponse->status) {
|
||||
$body = \file_get_contents($sTmp);
|
||||
\unlink($sTmp);
|
||||
throw new \Exception(static::body2plain($body), $oResponse->status);
|
||||
}
|
||||
return $sTmp;
|
||||
}
|
||||
|
||||
private static function body2plain(string $body) : string
|
||||
{
|
||||
return \trim(\strip_tags(
|
||||
\preg_match('@<body[^>]*>(.*)</body@si', $body, $match) ? $match[1] : $body
|
||||
));
|
||||
}
|
||||
|
||||
}
|
|
@ -5,8 +5,10 @@
|
|||
<i class="icon-spinner" style="margin: 5px" data-bind="visible: packages.loading"></i>
|
||||
</div>
|
||||
|
||||
<div class="alert" style="margin-top: 10px;" data-bind="visible: !packages.real() && !packages.loading()"
|
||||
data-i18n="TAB_PACKAGES/ALERT_CANNOT_ACCESS_REPOSITORY"></div>
|
||||
<div class="alert" style="margin-top: 10px;" data-bind="visible: !packages.real() && !packages.loading()">
|
||||
<div data-i18n="TAB_PACKAGES/ALERT_CANNOT_ACCESS_REPOSITORY"></div>
|
||||
<!-- ko text: packages.error() --><!-- /ko -->
|
||||
</div>
|
||||
|
||||
<div class="alert" style="margin-top: 10px;" data-bind="visible: '' !== packagesError()">
|
||||
<button type="button" class="close" data-bind="click: function () { packagesError('') }">×</button>
|
||||
|
|
Loading…
Reference in a new issue