Compare commits

...

25 Commits
8.3.1 ... 8.3.6

Author SHA1 Message Date
Yuri Kuznetsov
abd2016444 8.3.6 2024-08-08 14:07:26 +03:00
Yuri Kuznetsov
c247a24db4 fix email edit access error 2024-08-08 13:41:03 +03:00
Yuri Kuznetsov
17c9379c15 fix dynamic logic date time zone 2024-07-15 23:39:00 +03:00
Yuri Kuznetsov
d6d83a209f label fixes 2024-07-15 23:12:43 +03:00
Yuri Kuznetsov
0ffe39cec8 8.3.5 2024-07-10 11:04:51 +03:00
Yuri Kuznetsov
3347b7fba8 email from address check 2024-07-08 18:43:32 +03:00
Yuri Kuznetsov
64aebdde6b icon color fix 2024-07-04 09:20:54 +03:00
Yuri Kuznetsov
f3ee5c654b 8.3.4 2024-07-01 16:35:35 +03:00
Yuri Kuznetsov
1a413cb54e check failed code attempts in a separate hook 2024-07-01 16:23:37 +03:00
Yuri Kuznetsov
41bcaf50c4 fix attachment-multiple render 2024-06-28 18:55:21 +03:00
Yuri Kuznetsov
bc7d9443b1 8.3.3 2024-06-27 15:54:59 +03:00
Yuri Kuznetsov
8188dc065b fix row actions returning undefined 2024-06-27 15:29:30 +03:00
Yuri Kuznetsov
a2025d0a89 fix props not initiated 2024-06-27 15:17:04 +03:00
Yuri Kuznetsov
1d31637c2e migrate: rebuild before each script 2024-06-27 11:53:13 +03:00
Yuri Kuznetsov
2ba808c371 auth limit denial reason check 2024-06-26 21:54:15 +03:00
Yuri Kuznetsov
6bce395daf fix dropdown empty 2024-06-26 13:51:19 +03:00
Yuri Kuznetsov
5b0787474e 8.3.2 2024-06-26 07:53:49 +03:00
Yuri Kuznetsov
8c87f20374 comment 2024-06-26 07:50:19 +03:00
Yuri Kuznetsov
ba35115a48 inline edit: revert initial attributes on error 2024-06-26 07:44:44 +03:00
Yuri Kuznetsov
069010d0fe log opened only for system user 2024-06-26 07:22:09 +03:00
Yuri Kuznetsov
fe5878fd99 2fa auth log records on fail 2024-06-25 20:39:44 +03:00
Yuri Kuznetsov
839ceea142 root api endpoint fix type 2024-06-25 08:35:56 +03:00
Yuri Kuznetsov
9cce9d7347 comment 2024-06-24 11:16:45 +03:00
Yuri Kuznetsov
69d0dbbf1c email collapse fix 2024-06-24 11:09:29 +03:00
Yuri Kuznetsov
68ef9ce4ac disable email field layouts 2024-06-24 10:54:48 +03:00
31 changed files with 347 additions and 36 deletions

View File

@@ -0,0 +1,101 @@
<?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\Classes\RecordHooks\Email;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Mail\Account\SendingAccountProvider;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Core\Utils\Config;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\ORM\Entity;
/**
* @implements SaveHook<Email>
*/
class CheckFromAddress implements SaveHook
{
public function __construct(
private User $user,
private SendingAccountProvider $sendingAccountProvider,
private Config $config,
private Acl $acl,
) {}
public function process(Entity $entity): void
{
if ($this->user->isAdmin()) {
return;
}
$fromAddress = $entity->getFromAddress();
// Should be after 'getFromAddress'.
if (!$entity->isAttributeChanged('from')) {
return;
}
if (!$fromAddress) {
throw new BadRequest("No 'from' address");
}
if ($this->acl->checkScope('Import')) {
return;
}
$fromAddress = strtolower($fromAddress);
foreach ($this->user->getEmailAddressGroup()->getAddressList() as $address) {
if ($fromAddress === strtolower($address)) {
return;
}
}
if ($this->sendingAccountProvider->getShared($this->user, $fromAddress)) {
return;
}
$system = $this->sendingAccountProvider->getSystem();
if (
$system &&
$this->config->get('outboundEmailIsShared') &&
$system->getEmailAddress()
) {
if ($fromAddress === strtolower($system->getEmailAddress())) {
return;
}
}
throw new Forbidden("Not allowed 'from' address.");
}
}

View File

@@ -29,10 +29,14 @@
namespace Espo\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Utils\Json;
class ApiIndex
{
public function getActionIndex(): string
public function getActionIndex(Request $request, Response $response): void
{
return "EspoCRM REST API";
$response->writeBody(Json::encode("EspoCRM REST API"));
}
}

View File

@@ -195,8 +195,12 @@ class Auth
throw new BadRequest("Auth: Bad authorization string provided.");
}
/** @var array{string, string} */
return explode(':', $stringDecoded, 2);
[$username, $password] = explode(':', $stringDecoded, 2);
$username = trim($username);
$password = trim($password);
return [$username, $password];
}
private function handleSecondStepRequired(Response $response, Result $result): void
@@ -316,6 +320,14 @@ class Auth
$username = $request->getServerParam('PHP_AUTH_USER');
$password = $request->getServerParam('PHP_AUTH_PW');
if (is_string($username)) {
$username = trim($username);
}
if (is_string($password)) {
$password = trim($password);
}
return [$username, $password];
}

View File

@@ -241,7 +241,7 @@ class Authentication
$result = $this->processTwoFactor($result, $request);
if ($result->isFail()) {
return $this->processFail($result, $data, $request);
return $this->processTwoFactorFail($result, $data, $request, $authLogRecord);
}
}
@@ -818,4 +818,22 @@ class Authentication
throw new Forbidden();
}
private function processTwoFactorFail(
Result $result,
AuthenticationData $data,
Request $request,
?AuthLogRecord $authLogRecord
): Result {
if ($authLogRecord) {
$authLogRecord
->setIsDenied()
->setDenialReason(AuthLogRecord::DENIAL_REASON_WRONG_CODE);
$this->entityManager->saveEntity($authLogRecord);
}
return $this->processFail($result, $data, $request);
}
}

View File

@@ -80,12 +80,12 @@ class FailedAttemptsLimit implements BeforeLogin
throw new RuntimeException($e->getMessage());
}
$apAddress = $this->util->obtainIpFromRequest($request);
$ipAddress = $this->util->obtainIpFromRequest($request);
$where = [
'requestTime>' => $requestTimeFrom->format('U'),
'ipAddress' => $apAddress,
'isDenied' => true,
'ipAddress' => $ipAddress,
];
$wasFailed = (bool) $this->entityManager
@@ -107,6 +107,6 @@ class FailedAttemptsLimit implements BeforeLogin
return;
}
throw new Forbidden("Max failed login attempts exceeded for IP address $apAddress.");
throw new Forbidden("Max failed login attempts exceeded for IP address $ipAddress.");
}
}

View File

@@ -0,0 +1,115 @@
<?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\Core\Authentication\Hook\Hooks;
use Espo\Core\Api\Request;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Authentication\ConfigDataProvider;
use Espo\Core\Authentication\Hook\BeforeLogin;
use Espo\Core\Exceptions\Forbidden;
use Espo\Entities\AuthLogRecord;
use Espo\ORM\EntityManager;
use DateTime;
use Exception;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class FailedCodeAttemptsLimit implements BeforeLogin
{
public function __construct(
private ConfigDataProvider $configDataProvider,
private EntityManager $entityManager,
) {}
/**
* @throws Forbidden
*/
public function process(AuthenticationData $data, Request $request): void
{
if (
$request->getHeader('Espo-Authorization-Code') === null ||
$this->configDataProvider->isAuthLogDisabled()
) {
return;
}
$isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true';
if ($isByTokenOnly) {
return;
}
$failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod();
$maxFailedAttempts = $this->configDataProvider->getMaxFailedAttemptNumber();
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
try {
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
}
catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$where = [
'requestTime>' => $requestTimeFrom->format('U'),
'isDenied' => true,
'username' => $data->getUsername(),
'denialReason' => AuthLogRecord::DENIAL_REASON_WRONG_CODE,
];
$wasFailed = (bool) $this->entityManager
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
->select(['id'])
->where($where)
->findOne();
if (!$wasFailed) {
return;
}
$failAttemptCount = $this->entityManager
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
->where($where)
->count();
if ($failAttemptCount <= $maxFailedAttempts) {
return;
}
$username = $data->getUsername() ?? '';
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
}
}

View File

@@ -77,8 +77,8 @@ abstract class Base
];
private ZipArchive $zipUtil;
private ?DatabaseHelper $databaseHelper;
private ?Helper $helper;
private ?DatabaseHelper $databaseHelper = null;
private ?Helper $helper = null;
public function __construct(
private Container $container,

View File

@@ -51,6 +51,13 @@ class AfterUpgradeRunner
throw new RuntimeException("No after-upgrade script $step.");
}
try {
$this->dataManager->rebuild();
}
catch (Error $e) {
throw new RuntimeException("Error while rebuild: " . $e->getMessage());
}
/** @var Script $script */
$script = $this->injectableFactory->createWith($className, ['isUpgrade' => false]);
$script->run();

View File

@@ -36,6 +36,7 @@ class AuthLogRecord extends Entity
public const ENTITY_TYPE = 'AuthLogRecord';
public const DENIAL_REASON_CREDENTIALS = 'CREDENTIALS';
public const DENIAL_REASON_WRONG_CODE = 'WRONG_CODE';
public const DENIAL_REASON_INACTIVE_USER = 'INACTIVE_USER';
public const DENIAL_REASON_IS_PORTAL_USER = 'IS_PORTAL_USER';
public const DENIAL_REASON_IS_NOT_PORTAL_USER = 'IS_NOT_PORTAL_USER';

View File

@@ -29,6 +29,7 @@
namespace Espo\Modules\Crm\EntryPoints;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Entities\MassEmail;
@@ -47,7 +48,8 @@ class CampaignTrackOpened implements EntryPoint
public function __construct(
private EntityManager $entityManager,
private LogService $service
private LogService $service,
private User $user,
) {}
/**
@@ -105,7 +107,9 @@ class CampaignTrackOpened implements EntryPoint
return;
}
$this->service->logOpened($campaignId, $queueItem);
if ($this->user->isSystem()) {
$this->service->logOpened($campaignId, $queueItem);
}
header('Content-Type: image/png');

View File

@@ -109,8 +109,11 @@ class Email extends Database implements
public function loadFromField(EmailEntity $entity): void
{
if ($entity->get('fromEmailAddressName')) {
$entity->set('from', $entity->get('fromEmailAddressName'));
$fromEmailAddressName = $entity->get('fromEmailAddressName');
if ($fromEmailAddressName && !$entity->isAttributeChanged('fromEmailAddressName')) {
$entity->set('from', $fromEmailAddressName);
$entity->setFetched('from', $fromEmailAddressName);
return;
}
@@ -122,6 +125,7 @@ class Email extends Database implements
if ($ea) {
$entity->set('from', $ea->get('name'));
$entity->setFetched('from', $ea->get('name'));
return;
}
@@ -132,6 +136,7 @@ class Email extends Database implements
}
$entity->set('from', null);
$entity->setFetched('from', null);
}
public function loadToField(EmailEntity $entity): void

View File

@@ -27,6 +27,7 @@
"options": {
"denialReason": {
"CREDENTIALS": "Invalid credentials",
"WRONG_CODE": "Wrong code",
"INACTIVE_USER": "Inactive user",
"IS_PORTAL_USER": "Portal user",
"IS_NOT_PORTAL_USER": "Not a portal user",

View File

@@ -238,6 +238,8 @@
"Change": "Change",
"Primary": "Primary",
"Save Filter": "Save Filter",
"Remove Filter": "Remove Filter",
"Ready": "Ready",
"Administration": "Administration",
"Run Import": "Run Import",
"Duplicate": "Duplicate",

View File

@@ -1,6 +1,7 @@
{
"beforeLoginHookClassNameList": [
"Espo\\Core\\Authentication\\Hook\\Hooks\\FailedAttemptsLimit"
"Espo\\Core\\Authentication\\Hook\\Hooks\\FailedAttemptsLimit",
"Espo\\Core\\Authentication\\Hook\\Hooks\\FailedCodeAttemptsLimit"
],
"onLoginHookClassNameList": [
"Espo\\Core\\Authentication\\Hook\\Hooks\\IpAddressWhitelist"

View File

@@ -81,8 +81,10 @@
"view": "views/email/fields/from-address-varchar",
"textFilterDisabled": true,
"customizationDisabled": true,
"layoutDefaultSidePanelDisabled": true,
"layoutMassUpdateDisabled": true
"layoutAvailabilityList": [
"detail",
"filters"
]
},
"to": {
"type": "varchar",
@@ -97,8 +99,10 @@
],
"textFilterDisabled": true,
"customizationDisabled": true,
"layoutDefaultSidePanelDisabled": true,
"layoutMassUpdateDisabled": true
"layoutAvailabilityList": [
"detail",
"filters"
]
},
"cc": {
"type": "varchar",
@@ -111,8 +115,9 @@
],
"customizationDisabled": true,
"textFilterDisabled": true,
"layoutDefaultSidePanelDisabled": true,
"layoutMassUpdateDisabled": true
"layoutAvailabilityList": [
"detail"
]
},
"bcc": {
"type": "varchar",
@@ -125,8 +130,9 @@
],
"customizationDisabled": true,
"textFilterDisabled": true,
"layoutDefaultSidePanelDisabled": true,
"layoutMassUpdateDisabled": true
"layoutAvailabilityList": [
"detail"
]
},
"replyTo": {
"type": "varchar",
@@ -135,8 +141,9 @@
"view": "views/email/fields/email-address-varchar",
"textFilterDisabled": true,
"customizationDisabled": true,
"layoutDefaultSidePanelDisabled": true,
"layoutMassUpdateDisabled": true
"layoutAvailabilityList": [
"detail"
]
},
"personStringData": {
"type": "varchar",

View File

@@ -40,9 +40,11 @@
"Espo\\Classes\\RecordHooks\\Email\\MarkAsRead"
],
"beforeCreateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\Email\\CheckFromAddress",
"Espo\\Classes\\RecordHooks\\Email\\BeforeCreate"
],
"beforeUpdateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\Email\\CheckFromAddress",
"Espo\\Classes\\RecordHooks\\Email\\MarkAsReadBeforeUpdate",
"Espo\\Classes\\RecordHooks\\Email\\BeforeUpdate"
],

View File

@@ -138,6 +138,7 @@ class SendService
$userAddressList = [];
if ($user) {
// @todo Use getEmailAddressGroup.
/** @var Collection<EmailAddress> $emailAddressCollection */
$emailAddressCollection = $this->entityManager
->getRDBRepositoryByClass(User::class)

View File

@@ -2,7 +2,7 @@
<div class="panel-body">
<div class="cell form-group" data-name="name">
<label class="control-label" data-name="name">{{translate 'Name' category='fields'}}</label>
<label class="control-label" data-name="name">{{translate 'name' category='fields'}}</label>
<div class="field" data-name="name">
{{{name}}}
</div>

View File

@@ -263,20 +263,22 @@ class DateTime {
}
/**
* Convert a date to a moment.
* Convert a system-formatted date to a moment.
*
* @param {string} string A date value in a system representation.
* @returns {moment.Moment}
* @internal
*/
toMomentDate(string) {
return moment.utc(string, this.internalDateFormat);
return moment.tz(string, this.internalDateFormat, this.timeZone);
}
/**
* Convert a date-time to a moment.
* Convert a system-formatted date-time to a moment.
*
* @param {string} string A date-time value in a system representation.
* @returns {moment.Moment}
* @internal
*/
toMoment(string) {
let m = moment.utc(string, this.internalDateTimeFullFormat);

View File

@@ -69,6 +69,10 @@ function init(langSets) {
const options = context.options;
const view = /** @type {import('view').default} */options.espoView;
if (!view) {
return;
}
context.memo('button.cellParams', () => {
return ui.button({
className: '',
@@ -144,6 +148,12 @@ function init(langSets) {
const options = context.options;
const view = /** @type {import('view').default} */options.espoView;
if (!view) {
// Prevents an issue with a collapsed modal.
// @todo Revise.
return;
}
context.memo('button.tableParams', () => {
return ui.button({
className: '',

View File

@@ -505,7 +505,7 @@ class Dialog {
$main.append($button);
});
const allDdItemsHidden = this.dropdownItemList.filter(o => !o.hidden).length === 0;
const allDdItemsHidden = this.dropdownItemList.filter(o => o && !o.hidden).length === 0;
const $dropdown = $('<div>')
.addClass('btn-group')

View File

@@ -58,7 +58,7 @@ define('views/admin/layouts/modals/panel-attributes', ['views/modal', 'model'],
},
{
name: 'cancel',
text: 'Cancel',
label: 'Cancel',
},
];

View File

@@ -762,11 +762,12 @@ class AttachmentMultipleFieldView extends BaseFieldView {
if (this.isDetailMode() || this.isListMode()) {
const nameHash = this.nameHash;
const typeHash = this.model.get(this.typeHashName) || {};
const ids = /** @type {string[]} */this.model.get(this.idsName) || [];
const previews = [];
const names = [];
for (const id in nameHash) {
for (const id of ids) {
const type = typeHash[id] || false;
const name = nameHash[id];

View File

@@ -1253,6 +1253,7 @@ class BaseFieldView extends View {
if (isInvalid) {
Espo.Ui.error(this.translate('Not valid'));
// @todo Revise.
model.set(prev, {silent: true});
return;
@@ -1273,6 +1274,7 @@ class BaseFieldView extends View {
.catch(() => {
Espo.Ui.error(this.translate('Error occurred'));
// @todo Revise.
model.set(prev, {silent: true});
this.reRender();

View File

@@ -68,6 +68,7 @@ class SaveFiltersModalView extends ModalView {
},
mode: 'edit',
model: model,
labelText: this.translate('name', 'fields'),
});
}

View File

@@ -2085,7 +2085,10 @@ class DetailRecordView extends BaseRecordView {
return;
}
view.inlineEdit();
const initialAttributes = {...view.initialAttributes};
view.inlineEdit()
.then(() => view.initialAttributes = initialAttributes);
}
});
}

View File

@@ -43,6 +43,8 @@ class RelationshipUnlinkOnlyActionsView extends RelationshipActionsView {
},
];
}
return [];
}
}

View File

@@ -43,6 +43,8 @@ class RemoveOnlyRowActionsView extends DefaultRowActionsView {
}
];
}
return [];
}
}

View File

@@ -346,6 +346,13 @@ body[data-navbar="side"] {
color: var(--navbar-inverse-link-icon-color);
}
}
.more-dropdown-menu {
.short-label,
.more-icon {
color: var(--navbar-inverse-link-icon-color);
}
}
}
}
}

4
package-lock.json generated
View File

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

View File

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