Compare commits

..

12 Commits
8.1.1 ... 8.1.3

Author SHA1 Message Date
Yuri Kuznetsov
aced5fcab9 8.1.3 2024-02-01 16:14:48 +02:00
Yuri Kuznetsov
f786690f1e markdown fix 2024-02-01 15:58:10 +02:00
Yuri Kuznetsov
19d227e81d 8.1.2 2024-02-01 11:42:39 +02:00
Yuri Kuznetsov
00504630c6 fix sender property not set 2024-02-01 11:30:24 +02:00
Yuri Kuznetsov
4f6e1ed1ec fix scrollbar for chrome 121 2024-02-01 10:59:38 +02:00
Yuri Kuznetsov
e6d1048ebd fix markdown 2024-01-31 15:18:42 +02:00
Yuri Kuznetsov
3babdfa339 validate url 2024-01-30 17:40:38 +02:00
Yuri Kuznetsov
e95fcc6532 oidc popup login fix 2024-01-25 15:43:10 +02:00
Yuri Kuznetsov
d5b6c0aec1 fix layout 2024-01-25 15:37:05 +02:00
Yuri Kuznetsov
069d70176a fix log 2024-01-23 19:41:44 +02:00
Yuri Kuznetsov
0a4dba4343 markdown backtick fix 2024-01-20 21:37:10 +02:00
Yuri Kuznetsov
4c4c6d2402 use markdown extra in helper 2024-01-18 15:05:24 +02:00
13 changed files with 248 additions and 43 deletions

View File

@@ -32,7 +32,7 @@ namespace Espo\Classes\TemplateHelpers;
use Espo\Core\Htmlizer\Helper;
use Espo\Core\Htmlizer\Helper\Data;
use Espo\Core\Htmlizer\Helper\Result;
use Michelf\Markdown as MarkdownTransformer;
use Michelf\MarkdownExtra as MarkdownTransformer;
class MarkdownText implements Helper
{

View File

@@ -97,7 +97,7 @@ class EspoFileHandler extends MonologStreamHandler
$message = substr($record->message, 0, $this->maxErrorMessageLength) . '...';
$record = $record->with('message', $message);
$record = $record->with(message: $message);
return $this->getFormatter()->format($record);
}

View File

@@ -35,7 +35,7 @@ use Espo\Core\Utils\Config;
class SmsSender
{
private ?Sender $sender;
private ?Sender $sender = null;
public function __construct(
private InjectableFactory $injectableFactory,

View File

@@ -1,7 +1,7 @@
[
{"name":"username"},
{"name":"isDenied", "widthPx": "100"},
{"name":"ipAddress", "width": "15"},
{"name":"denialReason", "width": "22"},
{"name":"createdAt", "width": "15"}
{"name":"isDenied", "widthPx": 100},
{"name":"ipAddress", "width": 15},
{"name":"denialReason", "width": 22},
{"name":"createdAt", "width": 15}
]

View File

@@ -1,5 +1,5 @@
[
{"name":"username"},
{"name":"isDenied", "widthPx": "100"},
{"name":"createdAt", "width": "20"}
{"name":"isDenied", "widthPx": 100},
{"name":"createdAt", "width": 20}
]

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password\Recovery;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Config;
use Espo\Entities\Portal;
use Espo\ORM\EntityManager;
class UrlValidator
{
public function __construct(
private Config $config,
private EntityManager $entityManager
) {}
/**
* @throws Forbidden
*/
public function validate(string $url): void
{
$siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/');
if (str_starts_with($url, $siteUrl)) {
return;
}
/** @var iterable<Portal> $portals */
$portals = $this->entityManager
->getRDBRepositoryByClass(Portal::class)
->find();
foreach ($portals as $portal) {
$siteUrl = rtrim($portal->getUrl() ?? '', '/');
if (str_starts_with($url, $siteUrl)) {
return;
}
}
throw new Forbidden("URL does not match Site URL.");
}
}

View File

@@ -56,6 +56,7 @@ use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\TemplateFileManager;
use Espo\Tools\UserSecurity\Password\Jobs\RemoveRecoveryRequest;
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
class RecoveryService
{
@@ -75,7 +76,8 @@ class RecoveryService
private Log $log,
private JobSchedulerFactory $jobSchedulerFactory,
private ApplicationState $applicationState,
private AuthenticationMethodProvider $authenticationMethodProvider
private AuthenticationMethodProvider $authenticationMethodProvider,
private UrlValidator $urlValidator
) {}
/**
@@ -140,6 +142,10 @@ class RecoveryService
throw new Forbidden("Password recovery: Disabled.");
}
if ($url) {
$this->urlValidator->validate($url);
}
/** @var ?User $user */
$user = $this->entityManager
->getRDBRepository(User::ENTITY_TYPE)

View File

@@ -33,6 +33,12 @@ class OidcLoginHandler extends LoginHandler {
/** @inheritDoc */
process() {
const proxy = window.open(
'about:blank',
'ConnectWithOAuth',
'location=0,status=0,width=800,height=800'
);
Espo.Ui.notify(' ... ');
return new Promise((resolve, reject) => {
@@ -40,7 +46,7 @@ class OidcLoginHandler extends LoginHandler {
.then(data => {
Espo.Ui.notify(false);
this.processWithData(data)
this.processWithData(data, proxy)
.then(info => {
const code = info.code;
const nonce = info.nonce;
@@ -56,12 +62,14 @@ class OidcLoginHandler extends LoginHandler {
resolve(headers);
})
.catch(() => {
proxy.close();
reject();
});
})
.catch(() => {
Espo.Ui.notify(false)
proxy.close();
reject();
});
});
@@ -78,9 +86,10 @@ class OidcLoginHandler extends LoginHandler {
* prompt: 'login'|'consent'|'select_account',
* maxAge: ?Number,
* }} data
* @param {WindowProxy} proxy
* @return {Promise<{code: string, nonce: string}>}
*/
processWithData(data) {
processWithData(data, proxy) {
const state = (Math.random() + 1).toString(36).substring(7);
const nonce = (Math.random() + 1).toString(36).substring(7);
@@ -109,7 +118,7 @@ class OidcLoginHandler extends LoginHandler {
const url = data.endpoint + '?' + partList.join('&');
return this.processWindow(url, state, nonce);
return this.processWindow(url, state, nonce, proxy);
}
/**
@@ -117,10 +126,11 @@ class OidcLoginHandler extends LoginHandler {
* @param {string} url
* @param {string} state
* @param {string} nonce
* @param {WindowProxy} proxy
* @return {Promise<{code: string, nonce: string}>}
*/
processWindow(url, state, nonce) {
const proxy = window.open(url, 'ConnectWithOAuth', 'location=0,status=0,width=800,height=800');
processWindow(url, state, nonce, proxy) {
proxy.location.href = url;
return new Promise((resolve, reject) => {
const fail = () => {

View File

@@ -42,19 +42,20 @@ class ViewHelper {
/** @private */
this.mdBeforeList = [
/*{
regex: /```\n?([\s\S]*?)```/g,
value: (s, string) => {
return '```\n' + string.replace(/\\\>/g, '>') + '```';
},
},*/
{
regex: /&#x60;&#x60;&#x60;\n?([\s\S]*?)&#x60;&#x60;&#x60;/g,
value: function (s, string) {
return '<pre><code>' +string.replace(/\*/g, '&#42;').replace(/~/g, '&#126;') +
'</code></pre>';
}
// Also covers triple-backtick blocks.
regex: /`([\s\S]*?)`/g,
value: (s, string) => {
// noinspection RegExpRedundantEscape
return '`' + string.replace(/\\\>/g, '>') + '`';
},
},
{
regex: /&#x60;([\s\S]*?)&#x60;/g,
value: function (s, string) {
return '<code>' + string.replace(/\*/g, '&#42;').replace(/~/g, '&#126;') + '</code>';
}
}
];
marked.setOptions({
@@ -690,7 +691,8 @@ class ViewHelper {
transformMarkdownText(text, options) {
text = text || '';
text = Handlebars.Utils.escapeExpression(text).replace(/&gt;+/g, '>');
// noinspection RegExpRedundantEscape
text = text.replace(/\>/g, '\\>');
this.mdBeforeList.forEach(item => {
text = text.replace(item.regex, item.value);
@@ -698,12 +700,9 @@ class ViewHelper {
options = options || {};
if (options.inline) {
text = marked.parseInline(text);
}
else {
text = marked.parse(text);
}
text = options.inline ?
marked.parseInline(text) :
marked.parse(text);
text = DOMPurify.sanitize(text, {}).toString();

View File

@@ -3388,10 +3388,7 @@ a.close:hover {
opacity: 0.8;
}
.dashlet > .dashlet-body,
.list-kanban-container {
scrollbar-width: thin;
}
.wysiwyg-iframe-container.fallback {
border-radius: var(--border-radius);
@@ -3399,9 +3396,16 @@ a.close:hover {
background-color: var(--white-color);
}
* {
scrollbar-width: auto;
scrollbar-color: var(--scroll-thumb-bg) var(--scroll-bg);
@supports not selector(::-webkit-scrollbar) {
.dashlet > .dashlet-body,
.list-kanban-container {
scrollbar-width: thin;
}
* {
scrollbar-width: auto;
scrollbar-color: var(--scroll-thumb-bg) var(--scroll-bg);
}
}
*::-webkit-scrollbar {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "espocrm",
"version": "8.1.1",
"version": "8.1.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "espocrm",
"version": "8.1.1",
"version": "8.1.3",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "espocrm",
"version": "8.1.1",
"version": "8.1.3",
"description": "Open-source CRM.",
"repository": {
"type": "git",

View File

@@ -0,0 +1,116 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace tests\integration\Espo\Password;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Entities\Portal;
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
use tests\integration\Core\BaseTestCase;
class RecoveryTest extends BaseTestCase
{
private ?string $storedSiteUrl = null;
private string $siteUrl = 'https://my-site.com/';
protected function setUp(): void
{
parent::setUp();
$writer = $this->getInjectableFactory()->create(ConfigWriter::class);
$writer->set('siteUrl', $this->siteUrl);
$writer->save();
$this->storedSiteUrl = $this->getConfig()->get('siteUrl');
}
protected function tearDown(): void
{
$writer = $this->getInjectableFactory()->create(ConfigWriter::class);
$writer->set('siteUrl', $this->storedSiteUrl);
$writer->save();
$this->storedSiteUrl = null;
parent::tearDown();
}
public function testUrlValidation()
{
$em = $this->getEntityManager();
$em->createEntity(Portal::ENTITY_TYPE, [
'customUrl' => 'https://my-portal.com/',
]);
/** @var Portal $portal2 */
$portal2 = $em->createEntity(Portal::ENTITY_TYPE, [
'isDefault' => true,
]);
$validator = $this->getInjectableFactory()->create(UrlValidator::class);
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-site.com');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-site.com/');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-site.com#Test');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-site.com/portal');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-portal.com');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-portal.com/');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-portal.com/#Test');
/** @noinspection PhpUnhandledExceptionInspection */
$validator->validate('https://my-site.com/portal/' . $portal2->getId());
$thrown = false;
try {
$validator->validate('https://not-my-site.com');
}
catch (Forbidden) {
$thrown = true;
}
$this->assertTrue($thrown);
}
}