mirror of
https://github.com/espocrm/espocrm.git
synced 2026-03-07 13:27:00 +00:00
Compare commits
409 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a64537da1 | ||
|
|
6d3c765ef6 | ||
|
|
8f96c8c3e1 | ||
|
|
43749cbabb | ||
|
|
803185b747 | ||
|
|
4f5886fbd7 | ||
|
|
c28ba84f03 | ||
|
|
7f08df77f6 | ||
|
|
48116f8dcf | ||
|
|
0784fd589a | ||
|
|
8b8d3ee158 | ||
|
|
628e7e8131 | ||
|
|
408a161595 | ||
|
|
b46c6d76a5 | ||
|
|
d78f7d499a | ||
|
|
5662fed5eb | ||
|
|
3f4cdf629f | ||
|
|
bedab9c780 | ||
|
|
291bcf1684 | ||
|
|
bd9e1bf353 | ||
|
|
7d507460be | ||
|
|
99b4205181 | ||
|
|
5ae56454fc | ||
|
|
1e2369bba1 | ||
|
|
c42bfa3aab | ||
|
|
2b01cefb66 | ||
|
|
0b72848e33 | ||
|
|
ecc7f80c80 | ||
|
|
be5fff011b | ||
|
|
d7a29fdc71 | ||
|
|
e1c2203750 | ||
|
|
66cde8c86c | ||
|
|
10fb006fd8 | ||
|
|
b94c5ff869 | ||
|
|
39898bbbe8 | ||
|
|
62a6c2761f | ||
|
|
6fe6a52f47 | ||
|
|
c5d7fa5b0b | ||
|
|
ceaac6c3dc | ||
|
|
490843e371 | ||
|
|
b290fbfeb2 | ||
|
|
ca1e649b11 | ||
|
|
5e74cd8d06 | ||
|
|
63508423cc | ||
|
|
a5961811b4 | ||
|
|
fdb1595cd5 | ||
|
|
4bfedf8db3 | ||
|
|
325429eb52 | ||
|
|
e558a3139c | ||
|
|
e424c26963 | ||
|
|
b2cc1f97c4 | ||
|
|
d48ce0fc58 | ||
|
|
b61462a1cd | ||
|
|
1bb82151da | ||
|
|
9852e25c6e | ||
|
|
90fe358753 | ||
|
|
bd65e62fce | ||
|
|
fff9d7a781 | ||
|
|
8441fa05d2 | ||
|
|
9e86649b1c | ||
|
|
3c50a0e058 | ||
|
|
e571a63a30 | ||
|
|
14d405542b | ||
|
|
429d03504c | ||
|
|
fc24840a7b | ||
|
|
603ca985df | ||
|
|
a12940e36c | ||
|
|
dcab0ad7d0 | ||
|
|
89124b354b | ||
|
|
03c275f495 | ||
|
|
da89e0a321 | ||
|
|
90af51b8b9 | ||
|
|
92b82b9d4c | ||
|
|
c0aabe65d7 | ||
|
|
6e6478bb6a | ||
|
|
d457b461e7 | ||
|
|
2f6673f8d9 | ||
|
|
ee94408394 | ||
|
|
6d343cb564 | ||
|
|
f5c07c9d6b | ||
|
|
f9f2eaeb1f | ||
|
|
16d8015d92 | ||
|
|
2fa330d6f3 | ||
|
|
3bd2c8a813 | ||
|
|
12ba23e8ab | ||
|
|
e7c3314285 | ||
|
|
13ec8dd49f | ||
|
|
80146e8bca | ||
|
|
b974c673d2 | ||
|
|
c7ae23fd01 | ||
|
|
ccd21ebf76 | ||
|
|
f5c1fdebf3 | ||
|
|
863cd03eb3 | ||
|
|
d3eb08b171 | ||
|
|
5a39450e6d | ||
|
|
dd40786b6f | ||
|
|
35e894a391 | ||
|
|
8bd2b32908 | ||
|
|
ce58cc262f | ||
|
|
2dad119c3c | ||
|
|
7e872e845c | ||
|
|
678e39b0e5 | ||
|
|
ce10665bc3 | ||
|
|
d091ebbd23 | ||
|
|
68e495566d | ||
|
|
9943e413c1 | ||
|
|
8f4f4b4ac1 | ||
|
|
4a7f967d4f | ||
|
|
abf5690239 | ||
|
|
bc6946e6be | ||
|
|
06001a4b4c | ||
|
|
d5d913cf12 | ||
|
|
37f7fa8464 | ||
|
|
27c98bdf0f | ||
|
|
2c223a0739 | ||
|
|
3c756e6252 | ||
|
|
e7331efcbe | ||
|
|
62ae30aa8f | ||
|
|
c49f6c8045 | ||
|
|
16ce6eb0b9 | ||
|
|
701422892a | ||
|
|
abd2016444 | ||
|
|
cc68bd640e | ||
|
|
c247a24db4 | ||
|
|
b8a484cb7e | ||
|
|
61d546a649 | ||
|
|
23b9c08752 | ||
|
|
191f834e0f | ||
|
|
9fa9198963 | ||
|
|
f89e8acb79 | ||
|
|
33fbe125fa | ||
|
|
d93dd9f6df | ||
|
|
08841f7da5 | ||
|
|
725222e4c2 | ||
|
|
003f7864b3 | ||
|
|
48efcdb7a8 | ||
|
|
7bc8c2f161 | ||
|
|
4e3cb38477 | ||
|
|
20ac2dd2d7 | ||
|
|
0cabbaab01 | ||
|
|
8cc7adee3f | ||
|
|
9d297d5587 | ||
|
|
6e2409d969 | ||
|
|
b8cdbfb585 | ||
|
|
5c06345424 | ||
|
|
142cbfa4b8 | ||
|
|
04018bba93 | ||
|
|
e77143640b | ||
|
|
2964fff2cd | ||
|
|
0c8cdb61dc | ||
|
|
a9e0dad0dd | ||
|
|
b34d8ebca8 | ||
|
|
ed795d0d49 | ||
|
|
2673f60831 | ||
|
|
09b56fd8c0 | ||
|
|
44175614d4 | ||
|
|
1b7657bf0c | ||
|
|
469fcdb8bc | ||
|
|
fde53deae6 | ||
|
|
e470bf4eb1 | ||
|
|
5011f1e197 | ||
|
|
bc37a83982 | ||
|
|
d97b9be4c9 | ||
|
|
e51a9621f3 | ||
|
|
6dde915b7e | ||
|
|
a109e1353d | ||
|
|
8018478b13 | ||
|
|
9edb8bd59a | ||
|
|
3b3b05286a | ||
|
|
bb534e6c46 | ||
|
|
fce05fd5d2 | ||
|
|
875be06d28 | ||
|
|
0125ad0db9 | ||
|
|
4634c5d30f | ||
|
|
6bd717fe5d | ||
|
|
be56156516 | ||
|
|
3da90da35b | ||
|
|
6b9ebdc731 | ||
|
|
1d0d12649f | ||
|
|
e2aa06f755 | ||
|
|
19e4943410 | ||
|
|
668233a978 | ||
|
|
5a3bfe5f68 | ||
|
|
90ff998e5c | ||
|
|
71b7c1af2b | ||
|
|
997e5cc44c | ||
|
|
3f8e6b2854 | ||
|
|
3bf7ec7e9d | ||
|
|
1d7c47f005 | ||
|
|
b1d77bdca0 | ||
|
|
36a9563e69 | ||
|
|
67e1917426 | ||
|
|
344714fda0 | ||
|
|
836de2d624 | ||
|
|
2c5e548c07 | ||
|
|
f0ac0dc03e | ||
|
|
2bb53c5495 | ||
|
|
e0256018de | ||
|
|
019cea2641 | ||
|
|
d8286a2de6 | ||
|
|
685f034e15 | ||
|
|
3210733bc7 | ||
|
|
38ad6def8c | ||
|
|
b2d31d0230 | ||
|
|
ddd9a1dc88 | ||
|
|
3355ee7192 | ||
|
|
a4e694412c | ||
|
|
7222a5a546 | ||
|
|
423f26cb29 | ||
|
|
fe4679cbc5 | ||
|
|
df0c56bd9b | ||
|
|
269f38e07c | ||
|
|
48c89aec97 | ||
|
|
2cca4fe429 | ||
|
|
8672fd1dff | ||
|
|
755d3b863a | ||
|
|
453e2bd1a0 | ||
|
|
9ab35f457c | ||
|
|
0a60ffefa5 | ||
|
|
47fad3eb07 | ||
|
|
7cca2353d8 | ||
|
|
089c8d56f8 | ||
|
|
17c9379c15 | ||
|
|
d6d83a209f | ||
|
|
86b77266bb | ||
|
|
9339082f9b | ||
|
|
bac385545c | ||
|
|
3e4b4f2df8 | ||
|
|
990406889b | ||
|
|
487d8dc909 | ||
|
|
4945e19fdf | ||
|
|
0eecaf3d5a | ||
|
|
337dd67c36 | ||
|
|
e8f0d38554 | ||
|
|
e7904b976b | ||
|
|
2360b75f97 | ||
|
|
785c3a8545 | ||
|
|
1d1fccaed9 | ||
|
|
fb921bb023 | ||
|
|
0ffe39cec8 | ||
|
|
21034bfeb2 | ||
|
|
94188f4256 | ||
|
|
20f6d67f40 | ||
|
|
a3c289aee2 | ||
|
|
e56121bc18 | ||
|
|
5b90d4c3f3 | ||
|
|
f69eed63d7 | ||
|
|
06c173486e | ||
|
|
32f1bfb1c6 | ||
|
|
68fee2ec9f | ||
|
|
d15ab2df31 | ||
|
|
cb7f87a3f1 | ||
|
|
6783331aab | ||
|
|
88d4b6f27c | ||
|
|
3347b7fba8 | ||
|
|
cff703db05 | ||
|
|
16ba6ce7cf | ||
|
|
d4bda1fa9d | ||
|
|
06b5100e87 | ||
|
|
f1a3cd397a | ||
|
|
2481e1a652 | ||
|
|
e32ad76590 | ||
|
|
81f1374f55 | ||
|
|
d3fd314e35 | ||
|
|
20b1b06d5f | ||
|
|
a364ae1923 | ||
|
|
bb2ce37a38 | ||
|
|
fd092a3eb1 | ||
|
|
fb3bdde2c3 | ||
|
|
66623c02e2 | ||
|
|
22fded93fb | ||
|
|
6da20c6f86 | ||
|
|
34834b2d5c | ||
|
|
c971304b03 | ||
|
|
6c7d424349 | ||
|
|
f8e43a3694 | ||
|
|
17dddbc248 | ||
|
|
2246ab0cfd | ||
|
|
a42f2cbe3a | ||
|
|
ad4c039b4a | ||
|
|
2638fdb884 | ||
|
|
c0cfe8a36f | ||
|
|
b9ab872ccd | ||
|
|
018b0b46aa | ||
|
|
7bcd347f2d | ||
|
|
a6db71957a | ||
|
|
1a5df659cf | ||
|
|
2219f52140 | ||
|
|
78dde9c7a1 | ||
|
|
3f0e2a242d | ||
|
|
64aebdde6b | ||
|
|
24f6db674f | ||
|
|
bfc4fb7ca8 | ||
|
|
d46945dd2e | ||
|
|
51130d1aef | ||
|
|
6eb71a789e | ||
|
|
9c77169e6c | ||
|
|
26f1218240 | ||
|
|
8b09a81237 | ||
|
|
f3ee5c654b | ||
|
|
19c8fe9ac5 | ||
|
|
81f45d5679 | ||
|
|
1a413cb54e | ||
|
|
3ab2ffee3c | ||
|
|
53a481622a | ||
|
|
7d72a7ff71 | ||
|
|
314cc7f4c2 | ||
|
|
df1228f720 | ||
|
|
bbd071221a | ||
|
|
9235a3cf79 | ||
|
|
75c966e4bb | ||
|
|
c298f5ec9a | ||
|
|
94986d8835 | ||
|
|
ece8405b33 | ||
|
|
0d2d708ba8 | ||
|
|
ef31f7ba08 | ||
|
|
41bcaf50c4 | ||
|
|
dcd3fa0fc8 | ||
|
|
b48b9683ab | ||
|
|
2548f396ef | ||
|
|
0122b99a4b | ||
|
|
fb747d3f65 | ||
|
|
41cb0de44e | ||
|
|
f7442be97b | ||
|
|
20ccacddf3 | ||
|
|
1ce56bb522 | ||
|
|
f2330b9a51 | ||
|
|
0346a0023e | ||
|
|
bc7d9443b1 | ||
|
|
8188dc065b | ||
|
|
a2025d0a89 | ||
|
|
c46933f427 | ||
|
|
1d31637c2e | ||
|
|
e26026c9ad | ||
|
|
f9e4d1a953 | ||
|
|
6f00a6b2e7 | ||
|
|
a89860dc52 | ||
|
|
626c23b1b5 | ||
|
|
7513d05451 | ||
|
|
5e7aabc46c | ||
|
|
740baffeb4 | ||
|
|
2ba808c371 | ||
|
|
8c7f9f43e4 | ||
|
|
2df98585f9 | ||
|
|
9d4266bed0 | ||
|
|
cb313cd7ef | ||
|
|
ce54d516e9 | ||
|
|
6bce395daf | ||
|
|
b634daca6e | ||
|
|
338e0bb9d4 | ||
|
|
cc576a6af8 | ||
|
|
cbb0159d27 | ||
|
|
515b43614b | ||
|
|
736d23fa6d | ||
|
|
7a096cdde4 | ||
|
|
ee37960259 | ||
|
|
3ce8fae228 | ||
|
|
13ebc558e9 | ||
|
|
3ae342c275 | ||
|
|
a283305c7b | ||
|
|
a6c698588c | ||
|
|
3007976506 | ||
|
|
8189f0eb2f | ||
|
|
248063f327 | ||
|
|
3a76807a2b | ||
|
|
6e6d3e15ea | ||
|
|
eee086ef52 | ||
|
|
92695c0c39 | ||
|
|
44346e962c | ||
|
|
83a415cb33 | ||
|
|
02efdf11f5 | ||
|
|
26a0c4b108 | ||
|
|
caeaf46403 | ||
|
|
c254f5cc0d | ||
|
|
53c3f4b4c3 | ||
|
|
53a12e17f8 | ||
|
|
2ab5489bec | ||
|
|
a5c87272b1 | ||
|
|
130bae6a88 | ||
|
|
c520a02ca5 | ||
|
|
b8d5612d32 | ||
|
|
6ecf5fcdd0 | ||
|
|
5a93722232 | ||
|
|
d6035523e2 | ||
|
|
8b49e72f2b | ||
|
|
a1292cf933 | ||
|
|
5ccfd77669 | ||
|
|
67a27ec21b | ||
|
|
b1de15339a | ||
|
|
3f819500d3 | ||
|
|
435be717c3 | ||
|
|
d2f4fbc59d | ||
|
|
9e0a77588d | ||
|
|
d033b26e57 | ||
|
|
cbced73f6e | ||
|
|
9168dd69f2 | ||
|
|
c69fc7f2c9 | ||
|
|
faebc13757 | ||
|
|
bd6e0023c3 | ||
|
|
5f00c85882 | ||
|
|
6c8cffeb2a | ||
|
|
683eb5a491 | ||
|
|
669701c6fd | ||
|
|
7221963990 | ||
|
|
062043d5e1 | ||
|
|
adc3df5144 | ||
|
|
890649e46b | ||
|
|
33d710d265 | ||
|
|
b3f3226f55 |
10
.github/CONTRIBUTING.md
vendored
10
.github/CONTRIBUTING.md
vendored
@@ -1,14 +1,18 @@
|
||||
## Issues
|
||||
|
||||
When reporting a possible bug, provide detail steps so that we will be able
|
||||
to reproduce the issue. Try not to use phrases like "very big bug",
|
||||
to reproduce the issue. Steps to reproduce should be clear and unambiguous. Try not to use phrases like "very big bug",
|
||||
"huge issue", "useless feature", etc. No need to use exclamation marks as well.
|
||||
|
||||
Steps to reproduce should be clear and unambiguous.
|
||||
|
||||
Note that we don't provide developer help or any kind of support on GitHub.
|
||||
For this, please use our [forum](https://forum.espocrm.com).
|
||||
|
||||
If you are very new to EspoCRM, it's probable that an issue you ran into is not a bug.
|
||||
Consider creating a topic on our [forum](https://forum.espocrm.com/forum/general) instead.
|
||||
|
||||
The issue tracker is for the benefit of the EspoCRM project. The project maintainers are going to handle issues in the project's best interest.
|
||||
The maintainers have right to close issues without explanation.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
We are open for contributions that are bug fixes and small improvements. If you would like to contribute something that is not a small fix, please reach out to maintainers before submitting your PR (by creating a GitHub issue).
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: For high-level features, consider creating feature requests on the forum. For low-level (framework) – here on GitHub.
|
||||
about: For high-level features, create feature requests on our forum. For low-level (framework) – here on GitHub.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
2
.idea/codeStyles/Project.xml
generated
2
.idea/codeStyles/Project.xml
generated
@@ -17,8 +17,6 @@
|
||||
<codeStyleSettings language="PHP">
|
||||
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
|
||||
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
|
||||
<option name="CATCH_ON_NEW_LINE" value="true" />
|
||||
<option name="FINALLY_ON_NEW_LINE" value="true" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
|
||||
|
||||
1
.idea/inspectionProfiles/Project_Default.xml
generated
1
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -3,6 +3,7 @@
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="DuplicatedCode" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="ES6ConvertLetToConst" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
|
||||
<inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="JSIgnoredPromiseFromCall" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PhpDocMissingThrowsInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PhpDocSignatureIsNotCompleteInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
|
||||
@@ -134,9 +134,11 @@ module.exports = grunt => {
|
||||
src: [
|
||||
'build/tmp/custom/Espo/Custom/*',
|
||||
'!build/tmp/custom/Espo/Custom/.htaccess',
|
||||
'!build/tmp/custom/Espo/Modules',
|
||||
'build/tmp/custom/Espo/Modules/*',
|
||||
'!build/tmp/custom/Espo/Modules/.htaccess',
|
||||
'build/tmp/install/config.php',
|
||||
'build/tmp/vendor/*/*/.git',
|
||||
'build/tmp/custom/Espo/Custom/*',
|
||||
'build/tmp/client/custom/*',
|
||||
'!build/tmp/client/custom/modules',
|
||||
'build/tmp/client/custom/modules/*',
|
||||
|
||||
@@ -27,35 +27,21 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Hooks\User;
|
||||
namespace Espo\Classes\Acl\ImportEml;
|
||||
|
||||
use Espo\Core\Hook\Hook\BeforeRemove;
|
||||
use Espo\Core\Hook\Hook\BeforeSave;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Core\Acl\AccessCreateChecker;
|
||||
use Espo\Core\Acl\ScopeData;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\RemoveOptions;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
|
||||
/**
|
||||
* @implements BeforeRemove<User>
|
||||
* @implements BeforeSave<User>
|
||||
*/
|
||||
class DeleteId implements BeforeRemove, BeforeSave
|
||||
class AccessChecker implements AccessCreateChecker
|
||||
{
|
||||
public function beforeRemove(Entity $entity, RemoveOptions $options): void
|
||||
public function check(User $user, ScopeData $data): bool
|
||||
{
|
||||
$entity->set('deleteId', Util::generateId());
|
||||
return $data->isTrue();
|
||||
}
|
||||
|
||||
public function beforeSave(Entity $entity, SaveOptions $options): void
|
||||
public function checkCreate(User $user, ScopeData $data): bool
|
||||
{
|
||||
if (!$entity->isAttributeChanged('deleted')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleteId = $entity->get('deleted') ? Util::generateId() : '0';
|
||||
|
||||
$entity->set('deleteId', $deleteId);
|
||||
return $data->isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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\FieldProcessing\Email;
|
||||
|
||||
use Espo\Core\FieldProcessing\Loader;
|
||||
use Espo\Core\FieldProcessing\Loader\Params;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\EmailFolder;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @implements Loader<Email>
|
||||
*/
|
||||
class FolderDataLoader implements Loader
|
||||
{
|
||||
public function __construct(private EntityManager $entityManager) {}
|
||||
|
||||
public function process(Entity $entity, Params $params): void
|
||||
{
|
||||
$folderId = $entity->get(Email::USERS_COLUMN_FOLDER_ID);
|
||||
|
||||
if (!$folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$folder = $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailFolder::class)
|
||||
->select(['id', 'name'])
|
||||
->where(['id' => $folderId])
|
||||
->findOne();
|
||||
|
||||
if (!$folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set('folderName', $folder->getName());
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,10 @@ use Espo\Entities\User;
|
||||
*/
|
||||
class UserColumnsLoader implements Loader
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private User $user;
|
||||
|
||||
public function __construct(EntityManager $entityManager, User $user)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->user = $user;
|
||||
}
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity, Params $params): void
|
||||
{
|
||||
@@ -58,6 +54,7 @@ class UserColumnsLoader implements Loader
|
||||
Email::USERS_COLUMN_IS_READ,
|
||||
Email::USERS_COLUMN_IS_IMPORTANT,
|
||||
Email::USERS_COLUMN_IN_TRASH,
|
||||
Email::USERS_COLUMN_IN_ARCHIVE,
|
||||
])
|
||||
->where([
|
||||
'deleted' => false,
|
||||
@@ -70,6 +67,7 @@ class UserColumnsLoader implements Loader
|
||||
$entity->set(Email::USERS_COLUMN_IS_READ, null);
|
||||
$entity->clear(Email::USERS_COLUMN_IS_IMPORTANT);
|
||||
$entity->clear(Email::USERS_COLUMN_IN_TRASH);
|
||||
$entity->clear(Email::USERS_COLUMN_IN_ARCHIVE);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -78,6 +76,8 @@ class UserColumnsLoader implements Loader
|
||||
Email::USERS_COLUMN_IS_READ => $emailUser->get(Email::USERS_COLUMN_IS_READ),
|
||||
Email::USERS_COLUMN_IS_IMPORTANT => $emailUser->get(Email::USERS_COLUMN_IS_IMPORTANT),
|
||||
Email::USERS_COLUMN_IN_TRASH => $emailUser->get(Email::USERS_COLUMN_IN_TRASH),
|
||||
Email::USERS_COLUMN_IN_ARCHIVE => $emailUser->get(Email::USERS_COLUMN_IN_ARCHIVE),
|
||||
'isUsersSent' => $entity->getSentBy()?->getId() === $this->user->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**LICENSE**/
|
||||
|
||||
namespace Espo\Classes\FieldProcessing\InboundEmail;
|
||||
|
||||
use Espo\Core\FieldProcessing\Loader;
|
||||
use Espo\Core\FieldProcessing\Loader\Params;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\InboundEmail;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
/**
|
||||
* @implements Loader<InboundEmail>
|
||||
*/
|
||||
class IsSystemLoader implements Loader
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity, Params $params): void
|
||||
{
|
||||
$isSystem = $entity->getEmailAddress() === $this->config->get('outboundEmailFromAddress');
|
||||
|
||||
$entity->set('isSystem', $isSystem);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
@@ -26,27 +27,30 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
define('crm:views/campaign/subscribe-again', ['view'], function (Dep) {
|
||||
namespace Espo\Classes\FieldSanitizers;
|
||||
|
||||
return Dep.extend({
|
||||
use Espo\Core\FieldSanitize\Sanitizer;
|
||||
use Espo\Core\FieldSanitize\Sanitizer\Data;
|
||||
|
||||
template: 'crm:campaign/subscribe-again',
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class StringLowerCase implements Sanitizer
|
||||
{
|
||||
public function sanitize(Data $data, string $field): void
|
||||
{
|
||||
if (!$data->has($field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
data: function () {
|
||||
var revertUrl;
|
||||
$value = $data->get($field);
|
||||
|
||||
var actionData = this.options.actionData;
|
||||
if (!is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionData.hash && actionData.emailAddress) {
|
||||
revertUrl = '?entryPoint=unsubscribe&emailAddress=' + actionData.emailAddress +
|
||||
'&hash=' + actionData.hash;
|
||||
} else {
|
||||
revertUrl = '?entryPoint=unsubscribe&id=' + actionData.queueItemId;
|
||||
}
|
||||
$value = mb_strtolower($value);
|
||||
|
||||
return {
|
||||
revertUrl: revertUrl,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
$data->set($field, $value);
|
||||
}
|
||||
}
|
||||
@@ -29,16 +29,54 @@
|
||||
|
||||
namespace Espo\Classes\FieldValidators;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\ORM\Entity;
|
||||
use stdClass;
|
||||
|
||||
class IntType
|
||||
{
|
||||
public function __construct(
|
||||
private Defs $defs,
|
||||
) {}
|
||||
|
||||
public function checkRequired(Entity $entity, string $field): bool
|
||||
{
|
||||
return $this->isNotEmpty($entity, $field);
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function checkRangeInternal(Entity $entity, string $field): bool
|
||||
{
|
||||
$value = $entity->get($field);
|
||||
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$dbType = $this->defs
|
||||
->getEntity($entity->getEntityType())
|
||||
->tryGetAttribute($field)
|
||||
?->getParam('dbType') ?? Types::INTEGER;
|
||||
|
||||
$ranges = [
|
||||
Types::INTEGER => [-2147483648, 2147483647],
|
||||
Types::SMALLINT => [-32768, 32767],
|
||||
];
|
||||
|
||||
$range = $ranges[$dbType] ?? null;
|
||||
|
||||
if (!$range) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($value < $range[0] || $value > $range[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $validationValue
|
||||
* @noinspection PhpUnused
|
||||
|
||||
@@ -51,8 +51,8 @@ class AuthTokenControl implements JobDataLess
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$lifetime = (int) $this->config->get('authTokenLifetime', 0) * 60;
|
||||
$maxIdleTime = (int) $this->config->get('authTokenMaxIdleTime', 0) * 60;
|
||||
$lifetime = (int) ($this->config->get('authTokenLifetime', 0) * 60);
|
||||
$maxIdleTime = (int) ($this->config->get('authTokenMaxIdleTime', 0) * 60);
|
||||
|
||||
$portalIds = [];
|
||||
|
||||
@@ -69,11 +69,11 @@ class AuthTokenControl implements JobDataLess
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$itemLifetime = $portal->get('authTokenLifetime') !== null ?
|
||||
(int) $portal->get('authTokenLifetime') * 60 :
|
||||
(int) ($portal->get('authTokenLifetime') * 60) :
|
||||
$lifetime;
|
||||
|
||||
$itemMaxIdleTime = $portal->get('authTokenMaxIdleTime') !== null ?
|
||||
(int) $portal->get('authTokenMaxIdleTime') * 60 :
|
||||
(int) ($portal->get('authTokenMaxIdleTime') * 60) :
|
||||
$maxIdleTime;
|
||||
|
||||
$this->process($portal->getId(), $itemLifetime, $itemMaxIdleTime);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
namespace Espo\Classes\MassAction\Email;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\MassAction\Data;
|
||||
use Espo\Core\MassAction\MassAction;
|
||||
@@ -44,11 +45,10 @@ use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Email\Folder;
|
||||
use Espo\Tools\Email\InboxService as EmailService;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
class MoveToFolder implements MassAction
|
||||
{
|
||||
private const FOLDER_INBOX = Folder::INBOX;
|
||||
|
||||
public function __construct(
|
||||
private QueryBuilder $queryBuilder,
|
||||
private EntityManager $entityManager,
|
||||
@@ -68,7 +68,11 @@ class MoveToFolder implements MassAction
|
||||
throw new BadRequest("No folder ID.");
|
||||
}
|
||||
|
||||
if ($folderId !== self::FOLDER_INBOX && !str_starts_with($folderId, 'group:')) {
|
||||
if (
|
||||
$folderId !== Folder::INBOX &&
|
||||
$folderId !== Folder::ARCHIVE &&
|
||||
!str_starts_with($folderId, 'group:')
|
||||
) {
|
||||
$folder = $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailFolder::class)
|
||||
->where([
|
||||
@@ -93,7 +97,12 @@ class MoveToFolder implements MassAction
|
||||
}
|
||||
}
|
||||
|
||||
$query = $this->queryBuilder->build($params);
|
||||
try {
|
||||
$query = $this->queryBuilder->build($params);
|
||||
}
|
||||
catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Email::class)
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace Espo\Classes\MassAction\User;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\MassAction\Actions\MassDelete as MassDeleteOriginal;
|
||||
use Espo\Core\MassAction\Data;
|
||||
@@ -59,6 +60,7 @@ class MassDelete implements MassAction
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
*/
|
||||
public function process(Params $params, Data $data): Result
|
||||
{
|
||||
|
||||
@@ -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\Classes\RecordHooks\AddressCountry;
|
||||
|
||||
use Espo\Core\Exceptions\ConflictSilent;
|
||||
use Espo\Core\Exceptions\Error\Body;
|
||||
use Espo\Core\Record\Hook\SaveHook;
|
||||
use Espo\Entities\AddressCountry;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @implements SaveHook<AddressCountry>
|
||||
*/
|
||||
class BeforeSave implements SaveHook
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity): void
|
||||
{
|
||||
$where = ['name' => $entity->getName()];
|
||||
|
||||
if (!$entity->isNew()) {
|
||||
$where['id!='] = $entity->getId();
|
||||
}
|
||||
|
||||
$one = $this->entityManager
|
||||
->getRDBRepositoryByClass(AddressCountry::class)
|
||||
->where($where)
|
||||
->findOne();
|
||||
|
||||
if (!$one) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ConflictSilent::createWithBody(
|
||||
'duplicateError',
|
||||
Body::create()->withMessageTranslation('duplicateConflict')
|
||||
);
|
||||
}
|
||||
}
|
||||
101
application/Espo/Classes/RecordHooks/Email/CheckFromAddress.php
Normal file
101
application/Espo/Classes/RecordHooks/Email/CheckFromAddress.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,9 @@ class Main implements AdditionalApplier
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->checkApplyDateSentIndex($queryBuilder, $searchParams)) {
|
||||
/*if ($this->checkApplyDateSentIndex($queryBuilder, $searchParams)) {
|
||||
$queryBuilder->useIndex('dateSent');
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
private function joinEmailUser(SelectBuilder $queryBuilder): void
|
||||
@@ -93,6 +93,7 @@ class Main implements AdditionalApplier
|
||||
Email::USERS_COLUMN_IS_READ,
|
||||
Email::USERS_COLUMN_IS_IMPORTANT,
|
||||
Email::USERS_COLUMN_IN_TRASH,
|
||||
Email::USERS_COLUMN_IN_ARCHIVE,
|
||||
Email::USERS_COLUMN_FOLDER_ID,
|
||||
];
|
||||
|
||||
@@ -116,7 +117,7 @@ class Main implements AdditionalApplier
|
||||
return null;
|
||||
}
|
||||
|
||||
private function checkApplyDateSentIndex(SelectBuilder $queryBuilder, SearchParams $searchParams): bool
|
||||
/*private function checkApplyDateSentIndex(SelectBuilder $queryBuilder, SearchParams $searchParams): bool
|
||||
{
|
||||
if ($searchParams->getTextFilter()) {
|
||||
return false;
|
||||
@@ -149,5 +150,5 @@ class Main implements AdditionalApplier
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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\Select\Email\Where\ItemConverters;
|
||||
|
||||
use Espo\Core\Select\Helpers\RandomStringGenerator;
|
||||
use Espo\Core\Select\Where\Item;
|
||||
use Espo\Core\Select\Where\ItemConverter;
|
||||
use Espo\Classes\Select\Email\Helpers\EmailAddressHelper;
|
||||
use Espo\ORM\Query\Part\WhereClause;
|
||||
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
class CcEquals implements ItemConverter
|
||||
{
|
||||
public function __construct(
|
||||
private EmailAddressHelper $emailAddressHelper,
|
||||
private RandomStringGenerator $randomStringGenerator
|
||||
) {}
|
||||
|
||||
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
|
||||
{
|
||||
$value = $item->getValue();
|
||||
|
||||
if (!$value) {
|
||||
return WhereClause::fromRaw([
|
||||
'id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$emailAddressId = $this->emailAddressHelper->getEmailAddressIdByValue($value);
|
||||
|
||||
if (!$emailAddressId) {
|
||||
return WhereClause::fromRaw([
|
||||
'id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$queryBuilder->distinct();
|
||||
|
||||
$alias = 'emailEmailAddress' . $this->randomStringGenerator->generate();
|
||||
|
||||
$queryBuilder->leftJoin(
|
||||
'EmailEmailAddress',
|
||||
$alias,
|
||||
[
|
||||
'emailId:' => 'id',
|
||||
'deleted' => false,
|
||||
]
|
||||
);
|
||||
|
||||
return WhereClause::fromRaw([
|
||||
$alias . '.emailAddressId' => $emailAddressId,
|
||||
$alias . '.addressType' => 'cc',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\Select\Email\Where\ItemConverters;
|
||||
|
||||
use Espo\Core\Select\Where\Item;
|
||||
use Espo\Core\Select\Where\ItemConverter;
|
||||
use Espo\Classes\Select\Email\Helpers\JoinHelper;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Query\Part\WhereClause;
|
||||
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
class InArchiveIsFalse implements ItemConverter
|
||||
{
|
||||
public function __construct(private User $user, private JoinHelper $joinHelper)
|
||||
{}
|
||||
|
||||
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
|
||||
{
|
||||
$this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId());
|
||||
|
||||
return WhereClause::fromRaw([
|
||||
Email::ALIAS_INBOX . '.inArchive' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\Select\Email\Where\ItemConverters;
|
||||
|
||||
use Espo\Core\Select\Where\Item;
|
||||
use Espo\Core\Select\Where\ItemConverter;
|
||||
use Espo\Classes\Select\Email\Helpers\JoinHelper;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Query\Part\WhereClause;
|
||||
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
class InArchiveIsTrue implements ItemConverter
|
||||
{
|
||||
public function __construct(private User $user, private JoinHelper $joinHelper)
|
||||
{}
|
||||
|
||||
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
|
||||
{
|
||||
$this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId());
|
||||
|
||||
return WhereClause::fromRaw([
|
||||
Email::ALIAS_INBOX . '.inArchive' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ use Espo\Entities\User;
|
||||
use Espo\Classes\Select\Email\Helpers\JoinHelper;
|
||||
use Espo\Tools\Email\Folder;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class InFolder implements ItemConverter
|
||||
{
|
||||
public function __construct(
|
||||
@@ -59,17 +62,19 @@ class InFolder implements ItemConverter
|
||||
Folder::IMPORTANT => $this->convertImportant($queryBuilder),
|
||||
Folder::SENT => $this->convertSent($queryBuilder),
|
||||
Folder::TRASH => $this->convertTrash($queryBuilder),
|
||||
Folder::DRAFTS => $this->convertDraft($queryBuilder),
|
||||
Folder::ARCHIVE => $this->convertArchive($queryBuilder),
|
||||
Folder::DRAFTS => $this->convertDraft(),
|
||||
default => $this->convertFolderId($queryBuilder, $folderId),
|
||||
};
|
||||
}
|
||||
|
||||
protected function convertInbox(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
private function convertInbox(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
$whereClause = [
|
||||
Email::ALIAS_INBOX . '.inTrash' => false,
|
||||
Email::ALIAS_INBOX . '.inArchive' => false,
|
||||
Email::ALIAS_INBOX . '.folderId' => null,
|
||||
Email::ALIAS_INBOX . '.userId' => $this->user->getId(),
|
||||
[
|
||||
@@ -83,7 +88,7 @@ class InFolder implements ItemConverter
|
||||
|
||||
$emailAddressIdList = $this->getEmailAddressIdList();
|
||||
|
||||
if (!empty($emailAddressIdList)) {
|
||||
if ($emailAddressIdList !== []) {
|
||||
$whereClause['fromEmailAddressId!='] = $emailAddressIdList;
|
||||
|
||||
$whereClause[] = [
|
||||
@@ -92,8 +97,7 @@ class InFolder implements ItemConverter
|
||||
'createdById!=' => $this->user->getId(),
|
||||
],
|
||||
];
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$whereClause[] = [
|
||||
'status' => Email::STATUS_ARCHIVED,
|
||||
'createdById!=' => $this->user->getId(),
|
||||
@@ -103,7 +107,7 @@ class InFolder implements ItemConverter
|
||||
return WhereClause::fromRaw($whereClause);
|
||||
}
|
||||
|
||||
protected function convertSent(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
private function convertSent(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
@@ -122,7 +126,7 @@ class InFolder implements ItemConverter
|
||||
]);
|
||||
}
|
||||
|
||||
protected function convertImportant(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
private function convertImportant(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
@@ -132,7 +136,7 @@ class InFolder implements ItemConverter
|
||||
]);
|
||||
}
|
||||
|
||||
protected function convertTrash(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
private function convertTrash(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
@@ -142,7 +146,17 @@ class InFolder implements ItemConverter
|
||||
]);
|
||||
}
|
||||
|
||||
protected function convertDraft(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
private function convertArchive(QueryBuilder $queryBuilder): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
return WhereClause::fromRaw([
|
||||
Email::ALIAS_INBOX . '.userId' => $this->user->getId(),
|
||||
Email::ALIAS_INBOX . '.inArchive' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function convertDraft(): WhereClauseItem
|
||||
{
|
||||
return WhereClause::fromRaw([
|
||||
'status' => Email::STATUS_DRAFT,
|
||||
@@ -150,7 +164,7 @@ class InFolder implements ItemConverter
|
||||
]);
|
||||
}
|
||||
|
||||
protected function convertFolderId(QueryBuilder $queryBuilder, string $folderId): WhereClauseItem
|
||||
private function convertFolderId(QueryBuilder $queryBuilder, string $folderId): WhereClauseItem
|
||||
{
|
||||
$this->joinEmailUser($queryBuilder);
|
||||
|
||||
@@ -166,12 +180,14 @@ class InFolder implements ItemConverter
|
||||
'OR' => [
|
||||
Email::ALIAS_INBOX . '.id' => null,
|
||||
Email::ALIAS_INBOX . '.inTrash' => false,
|
||||
Email::ALIAS_INBOX . '.inArchive' => false,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
return WhereClause::fromRaw([
|
||||
Email::ALIAS_INBOX . '.inTrash' => false,
|
||||
Email::ALIAS_INBOX . '.inArchive' => false,
|
||||
Email::ALIAS_INBOX . '.folderId' => $folderId,
|
||||
'groupFolderId' => null,
|
||||
]);
|
||||
|
||||
47
application/Espo/Classes/Select/User/BoolFilters/OnlyMe.php
Normal file
47
application/Espo/Classes/Select/User/BoolFilters/OnlyMe.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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\Select\User\BoolFilters;
|
||||
|
||||
use Espo\Core\Select\Bool\Filter;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
class OnlyMe implements Filter
|
||||
{
|
||||
public function __construct(
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
|
||||
{
|
||||
$queryBuilder->where(['id' => $this->user->getId()]);
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,8 @@
|
||||
namespace Espo\Core\Acl\AccessChecker\AccessCheckers;
|
||||
|
||||
use Espo\Entities\User;
|
||||
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use Espo\Core\Acl\DefaultAccessChecker;
|
||||
use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency;
|
||||
use Espo\Core\Acl\AccessEntityCreateChecker;
|
||||
@@ -67,17 +64,12 @@ class Foreign implements
|
||||
{
|
||||
use DefaultAccessCheckerDependency;
|
||||
|
||||
private Metadata $metadata;
|
||||
private EntityManager $entityManager;
|
||||
|
||||
public function __construct(
|
||||
Metadata $metadata,
|
||||
private Metadata $metadata,
|
||||
DefaultAccessChecker $defaultAccessChecker,
|
||||
EntityManager $entityManager
|
||||
private EntityManager $entityManager
|
||||
) {
|
||||
$this->metadata = $metadata;
|
||||
$this->defaultAccessChecker = $defaultAccessChecker;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
private function getForeignEntity(Entity $entity): ?Entity
|
||||
@@ -121,7 +113,9 @@ class Foreign implements
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data);
|
||||
// @todo Check parent 'edit' access.
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityCreate($user, $foreign, $data);
|
||||
}
|
||||
|
||||
public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool
|
||||
@@ -132,7 +126,7 @@ class Foreign implements
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data);
|
||||
return $this->defaultAccessChecker->checkEntityRead($user, $foreign, $data);
|
||||
}
|
||||
|
||||
public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool
|
||||
@@ -143,7 +137,7 @@ class Foreign implements
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data);
|
||||
return $this->defaultAccessChecker->checkEntityEdit($user, $foreign, $data);
|
||||
}
|
||||
|
||||
public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool
|
||||
@@ -151,10 +145,14 @@ class Foreign implements
|
||||
$foreign = $this->getForeignEntity($entity);
|
||||
|
||||
if (!$foreign) {
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data);
|
||||
return $this->defaultAccessChecker->checkEntityDelete($user, $foreign, $data);
|
||||
}
|
||||
|
||||
public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool
|
||||
@@ -165,6 +163,6 @@ class Foreign implements
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->defaultAccessChecker->checkEntityStream($user, $entity, $data);
|
||||
return $this->defaultAccessChecker->checkEntityStream($user, $foreign, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamCheck
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var string[] $userTeamIdList */
|
||||
$userTeamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS);
|
||||
|
||||
if (
|
||||
|
||||
@@ -52,9 +52,7 @@ class DefaultTable implements Table
|
||||
protected string $type = 'acl';
|
||||
protected string $defaultAclType = 'recordAllTeamOwnNo';
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
private $actionList = [
|
||||
self::ACTION_READ,
|
||||
self::ACTION_STREAM,
|
||||
@@ -63,16 +61,12 @@ class DefaultTable implements Table
|
||||
self::ACTION_CREATE,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
private $booleanActionList = [
|
||||
self::ACTION_CREATE,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
protected $levelList = [
|
||||
self::LEVEL_YES,
|
||||
self::LEVEL_ALL,
|
||||
@@ -81,30 +75,23 @@ class DefaultTable implements Table
|
||||
self::LEVEL_NO,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
private $fieldActionList = [
|
||||
self::ACTION_READ,
|
||||
self::ACTION_EDIT,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
protected $fieldLevelList = [
|
||||
self::LEVEL_YES,
|
||||
self::LEVEL_NO,
|
||||
];
|
||||
|
||||
private stdClass $data;
|
||||
|
||||
private string $cacheKey;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
/** @var string[] */
|
||||
private $valuePermissionList = [];
|
||||
private ScopeDataResolver $scopeDataResolver;
|
||||
|
||||
public function __construct(
|
||||
private RoleListProvider $roleListProvider,
|
||||
@@ -112,7 +99,7 @@ class DefaultTable implements Table
|
||||
protected User $user,
|
||||
Config $config,
|
||||
protected Metadata $metadata,
|
||||
DataCache $dataCache
|
||||
DataCache $dataCache,
|
||||
) {
|
||||
|
||||
$this->data = (object) [
|
||||
@@ -135,14 +122,15 @@ class DefaultTable implements Table
|
||||
$cachedData = $dataCache->get($this->cacheKey);
|
||||
|
||||
$this->data = $cachedData;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$this->load();
|
||||
|
||||
if ($config->get('useCache')) {
|
||||
$dataCache->store($this->cacheKey, $this->data);
|
||||
}
|
||||
}
|
||||
|
||||
$this->scopeDataResolver = new ScopeDataResolver($this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,11 +144,7 @@ class DefaultTable implements Table
|
||||
|
||||
$data = $this->data->scopes->$scope;
|
||||
|
||||
if (is_string($data)) {
|
||||
return $this->getScopeData($data);
|
||||
}
|
||||
|
||||
return ScopeData::fromRaw($data);
|
||||
return $this->scopeDataResolver->resolve($data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
66
application/Espo/Core/Acl/Table/ScopeDataResolver.php
Normal file
66
application/Espo/Core/Acl/Table/ScopeDataResolver.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?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\Acl\Table;
|
||||
|
||||
use Espo\Core\Acl\ScopeData;
|
||||
use Espo\Core\Acl\Table;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ScopeDataResolver
|
||||
{
|
||||
public function __construct(
|
||||
private Table $table,
|
||||
) {}
|
||||
|
||||
public function resolve(mixed $data): ScopeData
|
||||
{
|
||||
if (!is_string($data)) {
|
||||
return ScopeData::fromRaw($data);
|
||||
}
|
||||
|
||||
$foreignScope = $data;
|
||||
$isBoolean = false;
|
||||
|
||||
if (str_starts_with($data, 'boolean:')) {
|
||||
[, $foreignScope] = explode(':', $data, 2);
|
||||
$isBoolean = true;
|
||||
}
|
||||
|
||||
$scopeData = $this->table->getScopeData($foreignScope);
|
||||
|
||||
if ($isBoolean && !$scopeData->isBoolean()) {
|
||||
return ScopeData::fromRaw(true);
|
||||
}
|
||||
|
||||
return $scopeData;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Api;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\ServiceUnavailable;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
@@ -47,8 +48,6 @@ use Exception;
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
private const HEADER_ESPO_AUTHORIZATION = 'Espo-Authorization';
|
||||
|
||||
public function __construct(
|
||||
private Log $log,
|
||||
private Authentication $authentication,
|
||||
@@ -275,7 +274,7 @@ class Auth
|
||||
|
||||
private function obtainAuthenticationMethodFromRequest(Request $request): ?string
|
||||
{
|
||||
if ($request->hasHeader(self::HEADER_ESPO_AUTHORIZATION)) {
|
||||
if ($request->hasHeader(HeaderKey::AUTHORIZATION)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -305,12 +304,10 @@ class Auth
|
||||
*/
|
||||
private function obtainUsernamePasswordFromRequest(Request $request): array
|
||||
{
|
||||
if ($request->hasHeader(self::HEADER_ESPO_AUTHORIZATION)) {
|
||||
[$username, $password] = $this->decodeAuthorizationString(
|
||||
$request->getHeader(self::HEADER_ESPO_AUTHORIZATION) ?? ''
|
||||
);
|
||||
if ($request->hasHeader(HeaderKey::AUTHORIZATION)) {
|
||||
$headerValue = $request->getHeader(HeaderKey::AUTHORIZATION) ?? '';
|
||||
|
||||
return [$username, $password];
|
||||
return $this->decodeAuthorizationString($headerValue);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -67,9 +67,7 @@ class Authentication
|
||||
{
|
||||
private const LOGOUT_USERNAME = '**logout';
|
||||
|
||||
private const HEADER_ESPO_AUTHORIZATION = 'Espo-Authorization';
|
||||
private const HEADER_CREATE_TOKEN_SECRET = 'Espo-Authorization-Create-Token-Secret';
|
||||
private const HEADER_BY_TOKEN = 'Espo-Authorization-By-Token';
|
||||
private const HEADER_ANOTHER_USER = 'X-Another-User';
|
||||
private const HEADER_LOGOUT_REDIRECT_URL = 'X-Logout-Redirect-Url';
|
||||
|
||||
@@ -155,7 +153,7 @@ class Authentication
|
||||
}
|
||||
}
|
||||
|
||||
$byTokenAndUsername = $request->getHeader(self::HEADER_BY_TOKEN) === 'true';
|
||||
$byTokenAndUsername = $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
|
||||
|
||||
if ($method && $byTokenAndUsername) {
|
||||
return Result::fail(FailReason::DISCREPANT_DATA);
|
||||
@@ -234,6 +232,7 @@ class Authentication
|
||||
$this->applicationUser->setUser($loggedUser);
|
||||
|
||||
if (
|
||||
!$result->bypassSecondStep() &&
|
||||
!$result->isSecondStepRequired() &&
|
||||
!$authToken &&
|
||||
$this->configDataProvider->isTwoFactorEnabled()
|
||||
@@ -253,7 +252,7 @@ class Authentication
|
||||
|
||||
if (
|
||||
!$result->isSecondStepRequired() &&
|
||||
$request->getHeader(self::HEADER_ESPO_AUTHORIZATION)
|
||||
$request->getHeader(HeaderKey::AUTHORIZATION)
|
||||
) {
|
||||
$authToken = $this->processAuthTokenFinal(
|
||||
$authToken,
|
||||
|
||||
@@ -37,6 +37,7 @@ use Espo\Core\Utils\Metadata;
|
||||
class ConfigDataProvider
|
||||
{
|
||||
private const FAILED_ATTEMPTS_PERIOD = '60 seconds';
|
||||
private const FAILED_CODE_ATTEMPTS_PERIOD = '5 minutes';
|
||||
private const MAX_FAILED_ATTEMPT_NUMBER = 10;
|
||||
|
||||
public function __construct(private Config $config, private Metadata $metadata)
|
||||
@@ -50,6 +51,14 @@ class ConfigDataProvider
|
||||
return $this->config->get('authFailedAttemptsPeriod', self::FAILED_ATTEMPTS_PERIOD);
|
||||
}
|
||||
|
||||
/**
|
||||
* A period for max failed 2FA code attempts checking.
|
||||
*/
|
||||
public function getFailedCodeAttemptsPeriod(): string
|
||||
{
|
||||
return $this->config->get('authFailedCodeAttemptsPeriod', self::FAILED_CODE_ATTEMPTS_PERIOD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Max failed log in attempts.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
@@ -26,7 +27,11 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
define('views/email-filter/fields/action', ['views/fields/enum'], function (Dep) {
|
||||
namespace Espo\Core\Authentication;
|
||||
|
||||
return Dep.extend({});
|
||||
});
|
||||
class HeaderKey
|
||||
{
|
||||
public const AUTHORIZATION_BY_TOKEN = 'Espo-Authorization-By-Token';
|
||||
public const AUTHORIZATION_CODE = 'Espo-Authorization-Code';
|
||||
public const AUTHORIZATION = 'Espo-Authorization';
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
namespace Espo\Core\Authentication\Hook\Hooks;
|
||||
|
||||
use Espo\Core\Api\Util;
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Authentication\Hook\BeforeLogin;
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Api\Request;
|
||||
@@ -58,45 +59,22 @@ class FailedAttemptsLimit implements BeforeLogin
|
||||
*/
|
||||
public function process(AuthenticationData $data, Request $request): void
|
||||
{
|
||||
$isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true';
|
||||
$isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
|
||||
|
||||
if ($isByTokenOnly) {
|
||||
if ($isByTokenOnly || $this->configDataProvider->isAuthLogDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configDataProvider->isAuthLogDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isSecondStep = $request->getHeader('Espo-Authorization-Code') !== null;
|
||||
|
||||
$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());
|
||||
}
|
||||
|
||||
$ipAddress = $this->util->obtainIpFromRequest($request);
|
||||
|
||||
$where = [
|
||||
'requestTime>' => $requestTimeFrom->format('U'),
|
||||
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'),
|
||||
'isDenied' => true,
|
||||
'ipAddress' => $ipAddress,
|
||||
];
|
||||
|
||||
if ($isSecondStep) {
|
||||
$where['username'] = $data->getUsername();
|
||||
}
|
||||
|
||||
if (!$isSecondStep) {
|
||||
$where['ipAddress'] = $ipAddress;
|
||||
}
|
||||
|
||||
$wasFailed = (bool) $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->select(['id'])
|
||||
@@ -112,16 +90,24 @@ class FailedAttemptsLimit implements BeforeLogin
|
||||
->where($where)
|
||||
->count();
|
||||
|
||||
if ($failAttemptCount <= $maxFailedAttempts) {
|
||||
if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isSecondStep) {
|
||||
$username = $data->getUsername() ?? '';
|
||||
|
||||
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
|
||||
}
|
||||
|
||||
throw new Forbidden("Max failed login attempts exceeded for IP address $ipAddress.");
|
||||
}
|
||||
|
||||
private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime
|
||||
{
|
||||
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
|
||||
|
||||
try {
|
||||
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
return $requestTimeFrom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?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->getFailedCodeAttemptsPeriod();
|
||||
|
||||
$where = [
|
||||
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->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 <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$username = $data->getUsername() ?? '';
|
||||
|
||||
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
|
||||
}
|
||||
|
||||
private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime
|
||||
{
|
||||
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
|
||||
|
||||
try {
|
||||
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
return $requestTimeFrom;
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,16 @@ class Hmac implements Login
|
||||
|
||||
$string = $request->getMethod() . ' ' . $request->getResourcePath();
|
||||
|
||||
// To become a legacy.
|
||||
if ($hash === ApiKey::hash($secretKey, $string)) {
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
// As of v8.4.1.
|
||||
if ($hash === hash_hmac('sha256', $string, $secretKey)) {
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::HASH_NOT_MATCHED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ class Login implements LoginInterface
|
||||
return Result::fail(FailReason::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return Result::success($user);
|
||||
return Result::success($user)->withBypassSecondStep();
|
||||
}
|
||||
|
||||
private function loginFallback(Data $data, Request $request): Result
|
||||
|
||||
@@ -51,6 +51,7 @@ class Result
|
||||
private ?string $token = null;
|
||||
private ?string $view = null;
|
||||
private ?string $failReason = null;
|
||||
private bool $bypassSecondStep = false;
|
||||
private ?Data $data;
|
||||
|
||||
private function __construct(string $status, ?User $user = null, ?Data $data = null)
|
||||
@@ -105,13 +106,23 @@ class Result
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step is required. E.g. for 2FA.
|
||||
* The second step is required.
|
||||
*/
|
||||
public function isSecondStepRequired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SECOND_STEP_REQUIRED;
|
||||
}
|
||||
|
||||
/**
|
||||
* To bypass the second step.
|
||||
*
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public function bypassSecondStep(): bool
|
||||
{
|
||||
return $this->bypassSecondStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login is failed.
|
||||
*/
|
||||
@@ -183,4 +194,17 @@ class Result
|
||||
{
|
||||
return $this->failReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone with bypass second step.
|
||||
*
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public function withBypassSecondStep(bool $bypassSecondStep = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->bypassSecondStep = $bypassSecondStep;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Utils\Log;
|
||||
@@ -56,7 +57,7 @@ class EmailLogin implements Login
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader('Espo-Authorization-Code');
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
@@ -55,7 +56,7 @@ class SmsLogin implements Login
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader('Espo-Authorization-Code');
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Totp;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
@@ -55,7 +56,7 @@ class TotpLogin implements Login
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader('Espo-Authorization-Code');
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
|
||||
@@ -30,60 +30,36 @@
|
||||
namespace Espo\Core\ExternalAccount;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
|
||||
use Espo\Entities\Integration as IntegrationEntity;
|
||||
use Espo\Entities\ExternalAccount as ExternalAccountEntity;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Entities\Integration;
|
||||
use Espo\Entities\ExternalAccount;
|
||||
use Espo\Entities\Notification;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
use Espo\Core\ExternalAccount\Clients\IClient;
|
||||
use Espo\Core\ExternalAccount\OAuth2\Client as OAuth2Client;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use Espo\ORM\Entity;
|
||||
use RuntimeException;
|
||||
|
||||
class ClientManager
|
||||
{
|
||||
/**
|
||||
* @var EntityManager
|
||||
*/
|
||||
protected $entityManager;
|
||||
private const REFRESH_TOKEN_ATTEMPTS_LIMIT = 20;
|
||||
private const REFRESH_TOKEN_ATTEMPTS_PERIOD = '1 day';
|
||||
|
||||
/**
|
||||
* @var Metadata
|
||||
*/
|
||||
protected $metadata;
|
||||
|
||||
/**
|
||||
* @var Config
|
||||
*/
|
||||
protected $config;
|
||||
|
||||
/**
|
||||
* @var InjectableFactory|null
|
||||
*/
|
||||
protected $injectableFactory = null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
/** @var array<string, (array<string, mixed> & array{externalAccountEntity: ExternalAccount})> */
|
||||
protected $clientMap = [];
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
Metadata $metadata,
|
||||
Config $config,
|
||||
?InjectableFactory $injectableFactory = null
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->metadata = $metadata;
|
||||
$this->config = $config;
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
}
|
||||
protected EntityManager $entityManager,
|
||||
protected Metadata $metadata,
|
||||
protected Config $config,
|
||||
protected ?InjectableFactory $injectableFactory = null,
|
||||
private ?Language $language = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
@@ -94,50 +70,61 @@ class ClientManager
|
||||
* } $data
|
||||
* @throws Error
|
||||
*/
|
||||
public function storeAccessToken(string $hash, array $data): void
|
||||
public function storeAccessToken(object $client, array $data): void
|
||||
{
|
||||
if (empty($this->clientMap[$hash]) || empty($this->clientMap[$hash]['externalAccountEntity'])) {
|
||||
try {
|
||||
$account = $this->getClientRecord($client);
|
||||
}
|
||||
catch (Error) {
|
||||
// @todo Revise.
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ExternalAccountEntity $externalAccountEntity */
|
||||
$externalAccountEntity = $this->clientMap[$hash]['externalAccountEntity'];
|
||||
|
||||
$externalAccountEntity->set('accessToken', $data['accessToken']);
|
||||
$externalAccountEntity->set('tokenType', $data['tokenType']);
|
||||
$externalAccountEntity->set('expiresAt', $data['expiresAt'] ?? null);
|
||||
$account->setAccessToken($data['accessToken']);
|
||||
$account->setTokenType($data['tokenType']);
|
||||
$account->setExpiresAt($data['expiresAt'] ?? null);
|
||||
$account->setRefreshTokenAttempts(null);
|
||||
|
||||
if ($data['refreshToken'] ?? null) {
|
||||
$externalAccountEntity->set('refreshToken', $data['refreshToken']);
|
||||
$account->setRefreshToken($data['refreshToken']);
|
||||
}
|
||||
|
||||
$copy = $this->entityManager->getEntity(ExternalAccountEntity::ENTITY_TYPE, $externalAccountEntity->getId());
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $account->getId());
|
||||
|
||||
if (!$copy) {
|
||||
return;
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Account removed.");
|
||||
}
|
||||
|
||||
if (!$copy->get('enabled')) {
|
||||
throw new Error("External Account Client Manager: Account got disabled.");
|
||||
if (!$account->isEnabled()) {
|
||||
throw new Error("External Account: Account disabled.");
|
||||
}
|
||||
|
||||
$copy->set('accessToken', $data['accessToken']);
|
||||
$copy->set('tokenType', $data['tokenType']);
|
||||
$copy->set('expiresAt', $data['expiresAt'] ?? null);
|
||||
$account->setAccessToken($data['accessToken']);
|
||||
$account->setTokenType($data['tokenType']);
|
||||
$account->setExpiresAt($data['expiresAt'] ?? null);
|
||||
$account->setRefreshTokenAttempts(null);
|
||||
|
||||
if ($data['refreshToken'] ?? null) {
|
||||
$copy->set('refreshToken', $data['refreshToken'] ?? null);
|
||||
$account->setRefreshToken($data['refreshToken'] ?? null);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($copy, [
|
||||
$this->entityManager->saveEntity($account, [
|
||||
'isTokenRenewal' => true,
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function create(string $integration, string $userId): ?object
|
||||
{
|
||||
$authMethod = $this->metadata->get("integrations.{$integration}.authMethod");
|
||||
$authMethod = $this->metadata->get("integrations.$integration.authMethod");
|
||||
|
||||
if (ucfirst($authMethod) === 'OAuth2') {
|
||||
return $this->createOAuth2($integration, $userId);
|
||||
}
|
||||
|
||||
$methodName = 'create' . ucfirst($authMethod);
|
||||
|
||||
@@ -146,101 +133,88 @@ class ClientManager
|
||||
}
|
||||
|
||||
if (!$this->injectableFactory) {
|
||||
throw new Error();
|
||||
throw new RuntimeException("No injectableFactory.");
|
||||
}
|
||||
|
||||
/** @var IntegrationEntity|null $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntity(IntegrationEntity::ENTITY_TYPE, $integration);
|
||||
/** @var ?Integration $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
|
||||
|
||||
/** @var ExternalAccountEntity|null $externalAccountEntity */
|
||||
$externalAccountEntity = $this->entityManager
|
||||
->getEntity(ExternalAccountEntity::ENTITY_TYPE, $integration . '__' . $userId);
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
|
||||
|
||||
if (!$externalAccountEntity) {
|
||||
throw new Error("External Account {$integration} not found for {$userId}.");
|
||||
if (!$account) {
|
||||
throw new Error("External Account $integration not found for $userId.");
|
||||
}
|
||||
|
||||
if (!$integrationEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$integrationEntity->get('enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$externalAccountEntity->get('enabled')) {
|
||||
if (!$integrationEntity || !$integrationEntity->isEnabled() || !$account->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string $className */
|
||||
$className = $this->metadata->get("integrations.{$integration}.clientClassName");
|
||||
$className = $this->metadata->get("integrations.$integration.clientClassName");
|
||||
|
||||
$client = $this->injectableFactory->create($className);
|
||||
|
||||
if (!method_exists($client, 'setup')) {
|
||||
throw new Error("{$className} does not have `setup` method.");
|
||||
throw new RuntimeException("$className does not have `setup` method.");
|
||||
}
|
||||
|
||||
$client->setup(
|
||||
$userId,
|
||||
$integrationEntity,
|
||||
$externalAccountEntity,
|
||||
$account,
|
||||
$this
|
||||
);
|
||||
|
||||
$this->addToClientMap($client, $integrationEntity, $externalAccountEntity, $userId);
|
||||
$this->addToClientMap($client, $integrationEntity, $account, $userId);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function createOAuth2(string $integration, string $userId): ?object
|
||||
{
|
||||
/** @var IntegrationEntity|null $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntity(IntegrationEntity::ENTITY_TYPE, $integration);
|
||||
/** @var ?Integration $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
|
||||
|
||||
/** @var ExternalAccountEntity|null $externalAccountEntity */
|
||||
$externalAccountEntity = $this->entityManager
|
||||
->getEntity(ExternalAccountEntity::ENTITY_TYPE, $integration . '__' . $userId);
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
|
||||
|
||||
/** @var class-string $className */
|
||||
$className = $this->metadata->get("integrations.{$integration}.clientClassName");
|
||||
|
||||
$className = $this->metadata->get("integrations.$integration.clientClassName");
|
||||
$redirectUri = $this->config->get('siteUrl') . '?entryPoint=oauthCallback';
|
||||
|
||||
$redirectUriPath = $this->metadata->get(['integrations', $integration, 'params', 'redirectUriPath']);
|
||||
|
||||
if ($redirectUriPath) {
|
||||
$redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/' . $redirectUriPath;
|
||||
}
|
||||
|
||||
if (!$externalAccountEntity) {
|
||||
throw new Error("External Account {$integration} not found for '{$userId}'.");
|
||||
if (!$account) {
|
||||
throw new Error("External Account $integration not found for '$userId'.");
|
||||
}
|
||||
|
||||
if (!$integrationEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$integrationEntity->get('enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$externalAccountEntity->get('enabled')) {
|
||||
if (
|
||||
!$integrationEntity ||
|
||||
!$integrationEntity->isEnabled() ||
|
||||
!$account->isEnabled()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$oauth2Client = new OAuth2Client();
|
||||
|
||||
$params = [
|
||||
'endpoint' => $this->metadata->get("integrations.{$integration}.params.endpoint"),
|
||||
'tokenEndpoint' => $this->metadata->get("integrations.{$integration}.params.tokenEndpoint"),
|
||||
'endpoint' => $this->metadata->get("integrations.$integration.params.endpoint"),
|
||||
'tokenEndpoint' => $this->metadata->get("integrations.$integration.params.tokenEndpoint"),
|
||||
'clientId' => $integrationEntity->get('clientId'),
|
||||
'clientSecret' => $integrationEntity->get('clientSecret'),
|
||||
'redirectUri' => $redirectUri,
|
||||
'accessToken' => $externalAccountEntity->get('accessToken'),
|
||||
'refreshToken' => $externalAccountEntity->get('refreshToken'),
|
||||
'tokenType' => $externalAccountEntity->get('tokenType'),
|
||||
'expiresAt' => $externalAccountEntity->get('expiresAt'),
|
||||
'accessToken' => $account->getAccessToken(),
|
||||
'refreshToken' => $account->getRefreshToken(),
|
||||
'tokenType' => $account->getTokenType(),
|
||||
'expiresAt' => $account->getExpiresAt() ? $account->getExpiresAt()->toString() : null,
|
||||
];
|
||||
|
||||
foreach (get_object_vars($integrationEntity->getValueMap()) as $k => $v) {
|
||||
@@ -263,13 +237,12 @@ class ClientManager
|
||||
'params' => $params,
|
||||
'manager' => $this,
|
||||
]);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// For backward compatibility.
|
||||
$client = new $className($oauth2Client, $params, $this);
|
||||
}
|
||||
|
||||
$this->addToClientMap($client, $integrationEntity, $externalAccountEntity, $userId);
|
||||
$this->addToClientMap($client, $integrationEntity, $account, $userId);
|
||||
|
||||
return $client;
|
||||
}
|
||||
@@ -280,16 +253,16 @@ class ClientManager
|
||||
*/
|
||||
protected function addToClientMap(
|
||||
$client,
|
||||
IntegrationEntity $integrationEntity,
|
||||
ExternalAccountEntity $externalAccountEntity,
|
||||
Integration $integration,
|
||||
ExternalAccount $account,
|
||||
string $userId
|
||||
) {
|
||||
$this->clientMap[spl_object_hash($client)] = [
|
||||
'client' => $client,
|
||||
'userId' => $userId,
|
||||
'integration' => $integrationEntity->getId(),
|
||||
'integrationEntity' => $integrationEntity,
|
||||
'externalAccountEntity' => $externalAccountEntity,
|
||||
'integration' => $integration->getId(),
|
||||
'integrationEntity' => $integration,
|
||||
'externalAccountEntity' => $account,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -297,12 +270,16 @@ class ClientManager
|
||||
* @param object $client
|
||||
* @throws Error
|
||||
*/
|
||||
protected function getClientRecord($client): Entity
|
||||
protected function getClientRecord($client): ExternalAccount
|
||||
{
|
||||
$data = $this->clientMap[spl_object_hash($client)];
|
||||
$data = $this->clientMap[spl_object_hash($client)] ?? null;
|
||||
|
||||
if (!$data) {
|
||||
throw new Error("External Account Client Manager: Client not found in hash.");
|
||||
throw new Error("External Account: Client not found in hash.");
|
||||
}
|
||||
|
||||
if (!isset($data['externalAccountEntity'])) {
|
||||
throw new Error("External Account: Account not found in hash.");
|
||||
}
|
||||
|
||||
return $data['externalAccountEntity'];
|
||||
@@ -314,91 +291,152 @@ class ClientManager
|
||||
*/
|
||||
public function isClientLocked($client): bool
|
||||
{
|
||||
$externalAccountEntity = $this->getClientRecord($client);
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$id = $externalAccountEntity->getId();
|
||||
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
|
||||
$e = $this->entityManager
|
||||
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
|
||||
->select(['id', 'isLocked'])
|
||||
->where(['id' => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$e) {
|
||||
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
|
||||
}
|
||||
|
||||
return $e->get('isLocked');
|
||||
return $account->isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function lockClient(object $client): void
|
||||
{
|
||||
$externalAccountEntity = $this->getClientRecord($client);
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$id = $externalAccountEntity->getId();
|
||||
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
$account->setIsLocked(true);
|
||||
|
||||
$e = $this->entityManager
|
||||
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
|
||||
->select(['id', 'isLocked'])
|
||||
->where(['id' => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$e) {
|
||||
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
|
||||
}
|
||||
|
||||
$e->set('isLocked', true);
|
||||
|
||||
$this->entityManager->saveEntity($e, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function unlockClient(object $client): void
|
||||
{
|
||||
$externalAccountEntity = $this->getClientRecord($client);
|
||||
|
||||
$id = $externalAccountEntity->getId();
|
||||
|
||||
$e = $this->entityManager
|
||||
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
|
||||
->select(['id', 'isLocked'])
|
||||
->where(['id' => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$e) {
|
||||
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
|
||||
}
|
||||
|
||||
$e->set('isLocked', false);
|
||||
|
||||
$this->entityManager->saveEntity($e, [
|
||||
$this->entityManager->saveEntity($account, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function unlockClient(object $client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$accountSet = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
$accountSet->setIsLocked(false);
|
||||
|
||||
$this->entityManager->saveEntity($accountSet, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function controlRefreshTokenAttempts(object $client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$account = $this->entityManager
|
||||
->getRDBRepositoryByClass(ExternalAccount::class)
|
||||
->getById($accountSet->getId());
|
||||
|
||||
if (!$account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attempts = $account->getRefreshTokenAttempts();
|
||||
|
||||
$account->setRefreshTokenAttempts($attempts + 1);
|
||||
|
||||
if (
|
||||
$attempts >= self::REFRESH_TOKEN_ATTEMPTS_LIMIT &&
|
||||
$account->getExpiresAt() &&
|
||||
$account->getExpiresAt()
|
||||
->modify('+' . self::REFRESH_TOKEN_ATTEMPTS_PERIOD)
|
||||
->isLessThan(DateTime::createNow())
|
||||
) {
|
||||
$account->setIsEnabled(false);
|
||||
$account->unsetData();
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($account, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
|
||||
if (!$account->isEnabled()) {
|
||||
$this->createDisableNotification($account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IClient $client
|
||||
* @throws Error
|
||||
*/
|
||||
public function reFetchClient(object $client): void
|
||||
public function reFetchClient($client): void
|
||||
{
|
||||
$externalAccountEntity = $this->getClientRecord($client);
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$id = $externalAccountEntity->getId();
|
||||
$id = $accountSet->getId();
|
||||
|
||||
$e = $this->entityManager->getEntityById(ExternalAccountEntity::ENTITY_TYPE, $id);
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$e) {
|
||||
throw new Error("External Account Client Manager: Client {$id} not found in DB.");
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Client $id not found in DB.");
|
||||
}
|
||||
|
||||
$data = $e->getValueMap();
|
||||
$data = $account->getValueMap();
|
||||
|
||||
$externalAccountEntity->set($data);
|
||||
$accountSet->set($data);
|
||||
|
||||
$client->setParams(get_object_vars($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function fetchAccountOnlyWithIsLocked(string $id): ExternalAccount
|
||||
{
|
||||
$account = $this->entityManager
|
||||
->getRDBRepository(ExternalAccount::ENTITY_TYPE)
|
||||
->select(['id', 'isLocked'])
|
||||
->where(['id' => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Client '$id' not found in DB.");
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function createDisableNotification(ExternalAccount $account): void
|
||||
{
|
||||
if (!str_contains($account->getId(), '__')) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$integration, $userId] = explode('__', $account->getId());
|
||||
|
||||
if (!$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->language) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $this->language->translateLabel('externalAccountNoConnectDisabled', 'messages', 'ExternalAccount');
|
||||
$message = str_replace('{integration}', $integration, $message);
|
||||
|
||||
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
|
||||
|
||||
$notification
|
||||
->setType(Notification::TYPE_MESSAGE)
|
||||
->setMessage($message)
|
||||
->setUserId($userId);
|
||||
|
||||
$this->entityManager->saveEntity($notification);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,3 @@ class Google extends OAuth2Abstract
|
||||
return 'https://www.googleapis.com/calendar/v3/users/me/calendarList';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
namespace Espo\Core\ExternalAccount\Clients;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Utils\DateTime as DateTimeUtil;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\ExternalAccount\ClientManager;
|
||||
use Espo\Core\ExternalAccount\OAuth2\Client;
|
||||
@@ -164,9 +165,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
*/
|
||||
protected function afterTokenRefreshed(array $data): void
|
||||
{
|
||||
if ($this->manager) {
|
||||
$this->manager->storeAccessToken(spl_object_hash($this), $data);
|
||||
}
|
||||
$this->manager?->storeAccessToken($this, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +183,6 @@ abstract class OAuth2Abstract implements IClient
|
||||
|
||||
$data['accessToken'] = $result['access_token'] ?? null;
|
||||
$data['tokenType'] = $result['token_type'] ?? null;
|
||||
|
||||
$data['expiresAt'] = null;
|
||||
|
||||
if (isset($result['refresh_token']) && $result['refresh_token'] !== $this->refreshToken) {
|
||||
@@ -194,7 +192,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
if (isset($result['expires_in']) && is_numeric($result['expires_in'])) {
|
||||
$data['expiresAt'] = (new DateTime())
|
||||
->modify('+' . $result['expires_in'] . ' seconds')
|
||||
->format('Y-m-d H:i:s');
|
||||
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,36 +226,34 @@ abstract class OAuth2Abstract implements IClient
|
||||
]
|
||||
);
|
||||
|
||||
if ($response['code'] == 200) {
|
||||
if (!empty($response['result'])) {
|
||||
/** @var array<string, mixed> $result */
|
||||
$result = $response['result'];
|
||||
|
||||
$data = $this->getAccessTokenDataFromResponseResult($result);
|
||||
|
||||
$data['refreshToken'] = $result['refresh_token'] ?? null;
|
||||
|
||||
/**
|
||||
* @var ?array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt: ?string,
|
||||
* refreshToken: ?string,
|
||||
* }
|
||||
*/
|
||||
return $data;
|
||||
}
|
||||
else {
|
||||
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($response['code'] != 200) {
|
||||
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (empty($response['result'])) {
|
||||
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $result */
|
||||
$result = $response['result'];
|
||||
|
||||
$data = $this->getAccessTokenDataFromResponseResult($result);
|
||||
|
||||
$data['refreshToken'] = $result['refresh_token'] ?? null;
|
||||
|
||||
/**
|
||||
* @var ?array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt: ?string,
|
||||
* refreshToken: ?string,
|
||||
* }
|
||||
*/
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,7 +277,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception $e) {
|
||||
catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -299,8 +295,8 @@ abstract class OAuth2Abstract implements IClient
|
||||
try {
|
||||
$dt = new DateTime($this->getParam('expiresAt'));
|
||||
}
|
||||
catch (Exception $e) {
|
||||
$this->log->debug("Oauth: Bad expires-at parameter stored for client {$this->clientId}.");
|
||||
catch (Exception) {
|
||||
$this->log->debug("Oauth: Bad expires-at parameter stored for client $this->clientId.");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -311,9 +307,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
return;
|
||||
}
|
||||
|
||||
$this->log->debug("Oauth: Refreshing expired token for client {$this->clientId}.");
|
||||
|
||||
$until = microtime(true) + $this::LOCK_TIMEOUT;
|
||||
$this->log->debug("Oauth: Refreshing expired token for client $this->clientId.");
|
||||
|
||||
if (!$this->isLocked()) {
|
||||
$this->refreshToken();
|
||||
@@ -321,11 +315,13 @@ abstract class OAuth2Abstract implements IClient
|
||||
return;
|
||||
}
|
||||
|
||||
$until = microtime(true) + $this::LOCK_TIMEOUT;
|
||||
|
||||
while (true) {
|
||||
usleep($this::LOCK_CHECK_STEP * 1000000);
|
||||
|
||||
if (!$this->isLocked()) { /** @phpstan-ignore-line */
|
||||
$this->log->debug("Oauth: Waited until unlocked for client {$this->clientId}.");
|
||||
if (!$this->isLocked()) {
|
||||
$this->log->debug("Oauth: Waited until unlocked for client $this->clientId.");
|
||||
|
||||
$this->reFetch();
|
||||
|
||||
@@ -333,7 +329,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
}
|
||||
|
||||
if (microtime(true) > $until) {
|
||||
$this->log->debug("Oauth: Waited until unlocked but timed out for client {$this->clientId}.");
|
||||
$this->log->debug("Oauth: Waited until unlocked but timed out for client $this->clientId.");
|
||||
|
||||
$this->unlock();
|
||||
|
||||
@@ -344,6 +340,10 @@ abstract class OAuth2Abstract implements IClient
|
||||
$this->refreshToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @phpstan-impure
|
||||
*/
|
||||
protected function isLocked(): bool
|
||||
{
|
||||
if (!$this->manager) {
|
||||
@@ -353,6 +353,9 @@ abstract class OAuth2Abstract implements IClient
|
||||
return $this->manager->isClientLocked($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function lock(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
@@ -362,6 +365,9 @@ abstract class OAuth2Abstract implements IClient
|
||||
$this->manager->lockClient($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function unlock(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
@@ -371,6 +377,21 @@ abstract class OAuth2Abstract implements IClient
|
||||
$this->manager->unlockClient($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function controlRefreshTokenAttempts(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->controlRefreshTokenAttempts($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function reFetch(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
@@ -387,7 +408,6 @@ abstract class OAuth2Abstract implements IClient
|
||||
* @param ?string $contentType
|
||||
* @param bool $allowRenew
|
||||
* @return mixed
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function request(
|
||||
@@ -406,14 +426,8 @@ abstract class OAuth2Abstract implements IClient
|
||||
$httpHeaders['Content-Type'] = $contentType;
|
||||
|
||||
switch ($contentType) {
|
||||
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
|
||||
if (is_string($params)) {
|
||||
$httpHeaders['Content-Length'] = (string) strlen($params);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case Client::CONTENT_TYPE_APPLICATION_JSON:
|
||||
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
|
||||
if (is_string($params)) {
|
||||
$httpHeaders['Content-Length'] = (string) strlen($params);
|
||||
}
|
||||
@@ -422,7 +436,12 @@ abstract class OAuth2Abstract implements IClient
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
|
||||
try {
|
||||
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
throw new Error($e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$code = null;
|
||||
|
||||
@@ -443,8 +462,7 @@ abstract class OAuth2Abstract implements IClient
|
||||
if ($this->refreshToken()) {
|
||||
return $this->request($url, $params, $httpMethod, $contentType, false);
|
||||
}
|
||||
}
|
||||
else if ($handledData['action'] === 'renew') {
|
||||
} else if ($handledData['action'] === 'renew') {
|
||||
return $this->request($url, $params, $httpMethod, $contentType, false);
|
||||
}
|
||||
}
|
||||
@@ -453,14 +471,12 @@ abstract class OAuth2Abstract implements IClient
|
||||
|
||||
if (
|
||||
is_array($result) &&
|
||||
isset($result['error']) &&
|
||||
is_array($result['error']) &&
|
||||
isset($result['error']['message'])
|
||||
) {
|
||||
$reasonPart = '; Reason: ' . $result['error']['message'];
|
||||
}
|
||||
|
||||
throw new Error("Oauth: Error after requesting {$httpMethod} {$url}{$reasonPart}.", (int) $code);
|
||||
throw new Error("Oauth: Error after requesting $httpMethod $url$reasonPart.", (int) $code);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,14 +487,15 @@ abstract class OAuth2Abstract implements IClient
|
||||
{
|
||||
if (empty($this->refreshToken)) {
|
||||
throw new Error(
|
||||
"Oauth: Could not refresh token for client {$this->clientId}, because refreshToken is empty."
|
||||
);
|
||||
"Oauth: Could not refresh token for client $this->clientId, because refreshToken is empty.");
|
||||
}
|
||||
|
||||
$this->lock();
|
||||
|
||||
assert(is_string($this->refreshToken));
|
||||
|
||||
try {
|
||||
$r = $this->client->getAccessToken(
|
||||
$response = $this->client->getAccessToken(
|
||||
$this->getParam('tokenEndpoint'),
|
||||
Client::GRANT_TYPE_REFRESH_TOKEN,
|
||||
['refresh_token' => $this->refreshToken]
|
||||
@@ -486,57 +503,50 @@ abstract class OAuth2Abstract implements IClient
|
||||
}
|
||||
catch (Exception $e) {
|
||||
$this->unlock();
|
||||
$this->controlRefreshTokenAttempts();
|
||||
|
||||
throw new Error("Oauth: Error while refreshing token: " . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($r['code'] == 200) {
|
||||
if (is_array($r['result'])) {
|
||||
if (!empty($r['result']['access_token'])) {
|
||||
$data = $this->getAccessTokenDataFromResponseResult($r['result']);
|
||||
if ($response['code'] == 200) {
|
||||
if (is_array($response['result']) && !empty($response['result']['access_token'])) {
|
||||
$data = $this->getAccessTokenDataFromResponseResult($response['result']);
|
||||
|
||||
$this->setParams($data);
|
||||
$this->afterTokenRefreshed($data);
|
||||
$this->setParams($data);
|
||||
$this->afterTokenRefreshed($data);
|
||||
$this->unlock();
|
||||
|
||||
$this->unlock();
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->unlock();
|
||||
$this->controlRefreshTokenAttempts();
|
||||
|
||||
$this->log->error("Oauth: Refreshing token failed for client {$this->clientId}: " . json_encode($r));
|
||||
$this->log->error("Oauth: Refreshing token failed for client $this->clientId: " . json_encode($response));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $r
|
||||
* @param array<string, mixed> $response
|
||||
* @return ?array{
|
||||
* action: string,
|
||||
* }
|
||||
*/
|
||||
protected function handleErrorResponse($r)
|
||||
protected function handleErrorResponse($response)
|
||||
{
|
||||
if ($r['code'] == 401 && !empty($r['result'])) {
|
||||
if (strpos($r['header'], 'error=invalid_token') !== false) {
|
||||
return [
|
||||
'action' => 'refreshToken'
|
||||
];
|
||||
}
|
||||
else {
|
||||
return [
|
||||
'action' => 'renew'
|
||||
];
|
||||
if ($response['code'] == 401 && !empty($response['result'])) {
|
||||
if (str_contains($response['header'], 'error=invalid_token')) {
|
||||
return ['action' => 'refreshToken'];
|
||||
}
|
||||
|
||||
return ['action' => 'renew'];
|
||||
}
|
||||
else if ($r['code'] == 400 && !empty($r['result'])) {
|
||||
if ($r['result']['error'] == 'invalid_token') {
|
||||
return [
|
||||
'action' => 'refreshToken'
|
||||
];
|
||||
|
||||
if ($response['code'] == 400 && !empty($response['result'])) {
|
||||
if ($response['result']['error'] == 'invalid_token') {
|
||||
return ['action' => 'refreshToken'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ class Client
|
||||
const TOKEN_TYPE_BEARER = 'Bearer';
|
||||
const TOKEN_TYPE_OAUTH = 'OAuth';
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
* @noinspection SpellCheckingInspection
|
||||
*/
|
||||
const CONTENT_TYPE_APPLICATION_X_WWW_FORM_URLENENCODED = 'application/x-www-form-urlencoded';
|
||||
const CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
|
||||
const CONTENT_TYPE_APPLICATION_JSON = 'application/json';
|
||||
@@ -57,57 +61,33 @@ class Client
|
||||
|
||||
const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
|
||||
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
|
||||
/** @noinspection PhpUnused */
|
||||
const GRANT_TYPE_PASSWORD = 'password';
|
||||
/** @noinspection PhpUnused */
|
||||
const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
private const REFRESH_TOKEN_TIMEOUT = 10;
|
||||
private const DEFAULT_TIMEOUT = 3600 * 2;
|
||||
|
||||
/** @var ?string */
|
||||
protected $clientId = null;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
/** @var ?string */
|
||||
protected $clientSecret = null;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
/** @var ?string */
|
||||
protected $accessToken = null;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
/** @var ?string */
|
||||
protected $expiresAt = null;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
/** @var int */
|
||||
protected $authType = self::AUTH_TYPE_URI;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
/** @var string */
|
||||
protected $tokenType = self::TOKEN_TYPE_URI;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
/** @var ?string */
|
||||
protected $accessTokenSecret = null;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
/** @var string */
|
||||
protected $accessTokenParamName = 'access_token';
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
/** @var ?string */
|
||||
protected $certificateFile = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
/** @var array<string, mixed> */
|
||||
protected $curlOptions = [];
|
||||
|
||||
public function __construct()
|
||||
@@ -120,6 +100,7 @@ class Client
|
||||
/**
|
||||
* @param string $clientId
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setClientId($clientId)
|
||||
{
|
||||
@@ -129,6 +110,7 @@ class Client
|
||||
/**
|
||||
* @param ?string $clientSecret
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setClientSecret($clientSecret)
|
||||
{
|
||||
@@ -138,6 +120,7 @@ class Client
|
||||
/**
|
||||
* @param ?string $accessToken
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAccessToken($accessToken)
|
||||
{
|
||||
@@ -147,6 +130,7 @@ class Client
|
||||
/**
|
||||
* @param int $authType
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAuthType($authType)
|
||||
{
|
||||
@@ -156,6 +140,7 @@ class Client
|
||||
/**
|
||||
* @param string $certificateFile
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCertificateFile($certificateFile)
|
||||
{
|
||||
@@ -166,6 +151,7 @@ class Client
|
||||
* @param string $option
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCurlOption($option, $value)
|
||||
{
|
||||
@@ -175,6 +161,7 @@ class Client
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCurlOptions($options)
|
||||
{
|
||||
@@ -184,6 +171,7 @@ class Client
|
||||
/**
|
||||
* @param string $tokenType
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setTokenType($tokenType)
|
||||
{
|
||||
@@ -193,6 +181,7 @@ class Client
|
||||
/**
|
||||
* @param ?string $value
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setExpiresAt($value)
|
||||
{
|
||||
@@ -202,6 +191,7 @@ class Client
|
||||
/**
|
||||
* @param ?string $accessTokenSecret
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAccessTokenSecret($accessTokenSecret)
|
||||
{
|
||||
@@ -221,8 +211,13 @@ class Client
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
public function request($url, $params = null, $httpMethod = self::HTTP_METHOD_GET, array $httpHeaders = [])
|
||||
{
|
||||
public function request(
|
||||
$url,
|
||||
$params = null,
|
||||
$httpMethod = self::HTTP_METHOD_GET,
|
||||
array $httpHeaders = []
|
||||
) {
|
||||
|
||||
if ($this->accessToken) {
|
||||
switch ($this->tokenType) {
|
||||
case self::TOKEN_TYPE_URI:
|
||||
@@ -265,15 +260,23 @@ class Client
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
private function execute($url, $params, $httpMethod, array $httpHeaders = [])
|
||||
{
|
||||
private function execute(
|
||||
$url,
|
||||
$params,
|
||||
$httpMethod,
|
||||
array $httpHeaders = [],
|
||||
?int $timeout = null
|
||||
) {
|
||||
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $httpMethod,
|
||||
CURLOPT_TIMEOUT => $timeout ?: self::DEFAULT_TIMEOUT,
|
||||
];
|
||||
|
||||
switch ($httpMethod) {
|
||||
/** @noinspection PhpMissingBreakStatementInspection */
|
||||
case self::HTTP_METHOD_POST:
|
||||
$curlOptions[CURLOPT_POST] = true;
|
||||
|
||||
@@ -289,13 +292,14 @@ class Client
|
||||
|
||||
break;
|
||||
|
||||
/** @noinspection PhpMissingBreakStatementInspection */
|
||||
case self::HTTP_METHOD_HEAD:
|
||||
$curlOptions[CURLOPT_NOBODY] = true;
|
||||
|
||||
case self::HTTP_METHOD_DELETE:
|
||||
case self::HTTP_METHOD_GET:
|
||||
|
||||
if (strpos($url, '?') === false) {
|
||||
if (!str_contains($url, '?')) {
|
||||
$url .= '?';
|
||||
}
|
||||
|
||||
@@ -320,7 +324,7 @@ class Client
|
||||
continue;
|
||||
}
|
||||
|
||||
$curlOptHttpHeader[] = "{$key}: {$value}";
|
||||
$curlOptHttpHeader[] = "$key: $value";
|
||||
}
|
||||
|
||||
$curlOptions[CURLOPT_HTTPHEADER] = $curlOptHttpHeader;
|
||||
@@ -330,7 +334,6 @@ class Client
|
||||
curl_setopt_array($ch, $curlOptions);
|
||||
|
||||
curl_setopt($ch, CURLOPT_HEADER, 1);
|
||||
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
|
||||
@@ -381,8 +384,11 @@ class Client
|
||||
* @param string $url
|
||||
* @param string $grantType
|
||||
* @param array{
|
||||
* client_id?: string,
|
||||
* client_secret?: string,
|
||||
* client_id?: string,
|
||||
* client_secret?: string,
|
||||
* redirect_uri?: string,
|
||||
* code?: string,
|
||||
* refresh_token?: string,
|
||||
* } $params
|
||||
* @return array{
|
||||
* result: array<string, mixed>|string,
|
||||
@@ -417,6 +423,6 @@ class Client
|
||||
throw new LogicException("Bad auth type.");
|
||||
}
|
||||
|
||||
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders);
|
||||
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders, self::REFRESH_TOKEN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ class Date implements DateTimeable
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated As of v8.1. Use `getTimestamp` instead.
|
||||
* @deprecated As of v8.1. Use `toTimestamp` instead.
|
||||
* @todo Remove in v10.0.
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
|
||||
@@ -385,7 +385,7 @@ class DateTime implements DateTimeable
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated As of v8.1. Use `getTimestamp` instead.
|
||||
* @deprecated As of v8.1. Use `toTimestamp` instead.
|
||||
* @todo Remove in v10.0.
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
|
||||
@@ -503,7 +503,7 @@ class DateTimeOptional implements DateTimeable
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated As of v8.1. Use `getTimestamp` instead.
|
||||
* @deprecated As of v8.1. Use `toTimestamp` instead.
|
||||
* @todo Remove in v10.0.
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
|
||||
@@ -81,8 +81,8 @@ class Link
|
||||
/**
|
||||
* Create from an ID.
|
||||
*/
|
||||
public static function create(string $id): self
|
||||
public static function create(string $id, ?string $name = null): self
|
||||
{
|
||||
return new self($id);
|
||||
return (new self($id))->withName($name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ class ListLoader implements LoaderInterface
|
||||
->getField($field)
|
||||
->getParam('columns');
|
||||
|
||||
if ($entity->has($field . 'Ids') && $entity->has($field . 'Names')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->loadLinkMultipleField($field, $columns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,18 @@ class LinkMultipleSaver
|
||||
$columns = $defs->getField($name)->getParam('columns');
|
||||
}
|
||||
|
||||
$allColumns = $columns;
|
||||
|
||||
if (is_array($columns)) {
|
||||
$additionalColumns = $defs->getRelation($name)->getParam('additionalColumns') ?? [];
|
||||
|
||||
foreach ($columns as $column => $field) {
|
||||
if (!array_key_exists($column, $additionalColumns)) {
|
||||
unset($columns[$column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$columnData = !empty($columns) ?
|
||||
$entity->get($columnsAttribute) :
|
||||
null;
|
||||
@@ -104,7 +116,7 @@ class LinkMultipleSaver
|
||||
foreach ($foreignEntityList as $foreignEntity) {
|
||||
$existingIdList[] = $foreignEntity->getId();
|
||||
|
||||
if (empty($columns)) {
|
||||
if (empty($allColumns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -112,7 +124,7 @@ class LinkMultipleSaver
|
||||
|
||||
$foreignId = $foreignEntity->getId();
|
||||
|
||||
foreach ($columns as $columnName => $columnField) {
|
||||
foreach ($allColumns as $columnName => $columnField) {
|
||||
$data->$columnName = $foreignEntity->get($columnField);
|
||||
}
|
||||
|
||||
@@ -129,7 +141,7 @@ class LinkMultipleSaver
|
||||
$entity->setFetched($idListAttribute, $existingIdList);
|
||||
}
|
||||
|
||||
if ($entity->has($columnsAttribute) && !empty($columns)) {
|
||||
if ($entity->has($columnsAttribute) && !empty($allColumns)) {
|
||||
$entity->setFetched($columnsAttribute, $existingColumnsData);
|
||||
}
|
||||
}
|
||||
@@ -175,8 +187,14 @@ class LinkMultipleSaver
|
||||
foreach ($toCreateIdList as $id) {
|
||||
$data = null;
|
||||
|
||||
if (!empty($columns) && isset($columnData->$id)) {
|
||||
if (is_array($columns) && isset($columnData->$id)) {
|
||||
$data = (array) $columnData->$id;
|
||||
|
||||
foreach ($data as $column => $v) {
|
||||
if (!array_key_exists($column, $columns)) {
|
||||
unset($data[$column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$repository->getRelation($entity, $name)->relateById($id, $data, [
|
||||
@@ -193,6 +211,14 @@ class LinkMultipleSaver
|
||||
foreach ($toUpdateIdList as $id) {
|
||||
$data = (array) $columnData->$id;
|
||||
|
||||
if (is_array($columns)) {
|
||||
foreach ($data as $column => $v) {
|
||||
if (!array_key_exists($column, $columns)) {
|
||||
unset($data[$column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$repository->getRelation($entity, $name)->updateColumnsById($id, (array) $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Formula;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Entities\EmailAddress;
|
||||
use Espo\Entities\PhoneNumber;
|
||||
use Espo\ORM\Entity;
|
||||
@@ -109,7 +110,7 @@ class AttributeFetcher
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entity->getAttributeParam($attribute, 'isLinkMultipleIdList')) {
|
||||
if ($entity->getAttributeParam($attribute, AttributeParam::IS_LINK_MULTIPLE_ID_LIST)) {
|
||||
/** @var ?string $relationName */
|
||||
$relationName = $entity->getAttributeParam($attribute, 'relation');
|
||||
|
||||
|
||||
@@ -177,12 +177,10 @@ class HookManager
|
||||
private function createHookByClassName(string $className): object
|
||||
{
|
||||
if (!class_exists($className)) {
|
||||
$this->log->error("Hook class '{$className}' does not exist.");
|
||||
$this->log->error("Hook class '$className' does not exist.");
|
||||
}
|
||||
|
||||
$obj = $this->injectableFactory->create($className);
|
||||
|
||||
return $obj;
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,8 +34,13 @@ use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMException;
|
||||
use DOMXPath;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Acl;
|
||||
@@ -43,7 +48,6 @@ use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ServiceFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\DateTime;
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
@@ -71,17 +75,18 @@ class Htmlizer
|
||||
private const LINK_LIMIT = 100;
|
||||
|
||||
public function __construct(
|
||||
private FileManager $fileManager, /** @phpstan-ignore-line */
|
||||
private DateTime $dateTime,
|
||||
private NumberUtil $number,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private User $user,
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata,
|
||||
private Language $language,
|
||||
private Config $config,
|
||||
private Log $log,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private ?Acl $acl = null,
|
||||
private ?EntityManager $entityManager = null,
|
||||
private ?Metadata $metadata = null,
|
||||
private ?Language $language = null,
|
||||
private ?Config $config = null,
|
||||
private ?ServiceFactory $serviceFactory = null,
|
||||
private ?Log $log = null,
|
||||
private ?InjectableFactory $injectableFactory = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -100,11 +105,13 @@ class Htmlizer
|
||||
bool $skipInlineAttachmentHandling = false
|
||||
): string {
|
||||
|
||||
$template = $this->prepare($template);
|
||||
$helpers = $this->getHelpers();
|
||||
|
||||
$template = $this->prepare($template, array_keys($helpers));
|
||||
|
||||
$code = LightnCandy::compile($template, [
|
||||
'flags' => Flags::FLAG_HANDLEBARSJS | Flags::FLAG_ERROR_EXCEPTION,
|
||||
'helpers' => $this->getHelpers(),
|
||||
'helpers' => $helpers,
|
||||
]);
|
||||
|
||||
if ($code === false) {
|
||||
@@ -154,10 +161,10 @@ class Htmlizer
|
||||
$html = str_replace('?entryPoint=attachment&', '?entryPoint=attachment&', $html);
|
||||
}
|
||||
|
||||
if (!$skipInlineAttachmentHandling && $this->entityManager) {
|
||||
if (!$skipInlineAttachmentHandling) {
|
||||
/** @var string $html */
|
||||
$html = preg_replace_callback(
|
||||
'/\?entryPoint=attachment\&id=([A-Za-z0-9]*)/',
|
||||
'/\?entryPoint=attachment&id=([A-Za-z0-9]*)/',
|
||||
function ($matches) {
|
||||
$id = $matches[1];
|
||||
|
||||
@@ -165,8 +172,6 @@ class Htmlizer
|
||||
return '';
|
||||
}
|
||||
|
||||
assert($this->entityManager !== null);
|
||||
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
|
||||
|
||||
@@ -249,7 +254,6 @@ class Htmlizer
|
||||
if (
|
||||
!$skipLinks &&
|
||||
$level === 0 &&
|
||||
$this->entityManager &&
|
||||
$entity->hasId()
|
||||
) {
|
||||
$this->loadRelatedCollections($entity, $template, $data);
|
||||
@@ -360,8 +364,7 @@ class Htmlizer
|
||||
}
|
||||
|
||||
if (
|
||||
$fieldType === 'currency' &&
|
||||
$this->metadata &&
|
||||
$fieldType === FieldType::CURRENCY &&
|
||||
$entity instanceof CoreEntity &&
|
||||
$entity->getAttributeParam($attribute, 'attributeRole') === 'currency'
|
||||
) {
|
||||
@@ -382,21 +385,16 @@ class Htmlizer
|
||||
|
||||
$fieldType = $this->getFieldType($entity->getEntityType(), $attribute);
|
||||
|
||||
if ($fieldType === 'enum') {
|
||||
if ($this->language) {
|
||||
$data[$attribute] = $this->language->translateOption(
|
||||
$data[$attribute], $attribute, $entity->getEntityType()
|
||||
);
|
||||
if ($fieldType === FieldType::ENUM) {
|
||||
$data[$attribute] = $this->language->translateOption(
|
||||
$data[$attribute], $attribute, $entity->getEntityType()
|
||||
);
|
||||
|
||||
if ($this->metadata) {
|
||||
$translationPath = $this->metadata->get(
|
||||
['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']
|
||||
);
|
||||
$translationPath = $this->metadata
|
||||
->get(['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']);
|
||||
|
||||
if ($translationPath) {
|
||||
$data[$attribute] = $this->language->get($translationPath . '.' . $attribute, $data[$attribute]);
|
||||
}
|
||||
}
|
||||
if ($translationPath) {
|
||||
$data[$attribute] = $this->language->get($translationPath . '.' . $attribute, $data[$attribute]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +402,7 @@ class Htmlizer
|
||||
}
|
||||
}
|
||||
|
||||
if (!$skipLinks && $this->entityManager) {
|
||||
if (!$skipLinks) {
|
||||
foreach ($entity->getRelationList() as $relation) {
|
||||
if (in_array($relation, $forbiddenLinkList)) {
|
||||
continue;
|
||||
@@ -413,26 +411,26 @@ class Htmlizer
|
||||
$relationType = $entity->getRelationType($relation);
|
||||
|
||||
if (
|
||||
$relationType === Entity::BELONGS_TO ||
|
||||
$relationType === Entity::BELONGS_TO_PARENT
|
||||
$relationType !== Entity::BELONGS_TO &&
|
||||
$relationType !== Entity::BELONGS_TO_PARENT
|
||||
) {
|
||||
$relatedEntity = $this->entityManager
|
||||
->getRDBRepository($entity->getEntityType())
|
||||
->getRelation($entity, $relation)
|
||||
->findOne();
|
||||
|
||||
if (!$relatedEntity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->acl) {
|
||||
if (!$this->acl->checkEntityRead($relatedEntity)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$data[$relation] = $this->getDataFromEntity($relatedEntity, true, $level + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
$relatedEntity = $this->entityManager
|
||||
->getRDBRepository($entity->getEntityType())
|
||||
->getRelation($entity, $relation)
|
||||
->findOne();
|
||||
|
||||
if (!$relatedEntity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->acl && !$this->acl->checkEntityRead($relatedEntity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[$relation] = $this->getDataFromEntity($relatedEntity, true, $level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,11 +456,7 @@ class Htmlizer
|
||||
*/
|
||||
private function loadRelatedCollection(Entity $entity, string $relation, ?string $template): ?Collection
|
||||
{
|
||||
assert($this->entityManager !== null);
|
||||
|
||||
$limit = $this->config ?
|
||||
$this->config->get('htmlizerLinkLimit', self::LINK_LIMIT) :
|
||||
self::LINK_LIMIT;
|
||||
$limit = $this->config->get('htmlizerLinkLimit', self::LINK_LIMIT);
|
||||
|
||||
$orderData = $this->getRelationOrder($entity->getEntityType(), $relation);
|
||||
|
||||
@@ -496,9 +490,33 @@ class Htmlizer
|
||||
) &&
|
||||
mb_stripos($template, '{{#each ' . $relation . '}}') !== false
|
||||
) {
|
||||
$foreignEntityType = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entity->getEntityType())
|
||||
->getRelation($relation)
|
||||
->getForeignEntityType();
|
||||
|
||||
$selectBuilder = $this->selectBuilderFactory->create();
|
||||
|
||||
$selectBuilder->from($foreignEntityType);
|
||||
|
||||
if ($this->acl) {
|
||||
$selectBuilder
|
||||
->forUser($this->user)
|
||||
->withAccessControlFilter();
|
||||
}
|
||||
|
||||
try {
|
||||
$query = $selectBuilder->build();
|
||||
}
|
||||
catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
return $this->entityManager
|
||||
->getRDBRepository($entity->getEntityType())
|
||||
->getRelation($entity, $relation)
|
||||
->clone($query)
|
||||
->limit(0, $limit)
|
||||
->order($orderData)
|
||||
->find();
|
||||
@@ -513,6 +531,63 @@ class Htmlizer
|
||||
private function getHelpers(): array
|
||||
{
|
||||
$helpers = [
|
||||
'and' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
if (count($args) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < count($args) - 1; $i++) {
|
||||
$arg = $args[$i];
|
||||
|
||||
if (!$arg) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
'or' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
for ($i = 0; $i < count($args) - 1; $i++) {
|
||||
$arg = $args[$i];
|
||||
|
||||
if (!$arg) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
'not' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
if (count($args) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$arg = $args[0];
|
||||
|
||||
return !$arg;
|
||||
},
|
||||
'equal' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
$arg1 = $args[0] ?? null;
|
||||
$arg2 = $args[1] ?? null;
|
||||
|
||||
return $arg1 === $arg2;
|
||||
},
|
||||
'notEqual' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
$arg1 = $args[0] ?? null;
|
||||
$arg2 = $args[1] ?? null;
|
||||
|
||||
return $arg1 !== $arg2;
|
||||
},
|
||||
'file' => function () {
|
||||
$args = func_get_args();
|
||||
|
||||
@@ -522,10 +597,14 @@ class Htmlizer
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @noinspection PhpUndefinedClassInspection */
|
||||
/** @noinspection PhpUndefinedNamespaceInspection */
|
||||
/** @phpstan-ignore-next-line */
|
||||
return new LightnCandy\SafeString("?entryPoint=attachment&id=" . $id);
|
||||
},
|
||||
'pagebreak' => function () {
|
||||
/** @noinspection PhpUndefinedClassInspection, HtmlUnknownAttribute */
|
||||
/** @noinspection PhpUndefinedNamespaceInspection */
|
||||
/** @phpstan-ignore-next-line */
|
||||
return new LightnCandy\SafeString('<br pagebreak="true">');
|
||||
},
|
||||
@@ -555,15 +634,18 @@ class Htmlizer
|
||||
$attributesPart = "";
|
||||
|
||||
if ($width) {
|
||||
$attributesPart .= " width=\"" .strval($width) . "\"";
|
||||
$attributesPart .= " width=\"$width\"";
|
||||
}
|
||||
|
||||
if ($height) {
|
||||
$attributesPart .= " height=\"" .strval($height) . "\"";
|
||||
$attributesPart .= " height=\"$height\"";
|
||||
}
|
||||
|
||||
$html = "<img src=\"?entryPoint=attachment&id={$id}\"{$attributesPart}>";
|
||||
/** @noinspection HtmlRequiredAltAttribute */
|
||||
$html = "<img src=\"?entryPoint=attachment&id=$id\"$attributesPart>";
|
||||
|
||||
/** @noinspection PhpUndefinedNamespaceInspection */
|
||||
/** @noinspection PhpUndefinedClassInspection */
|
||||
/** @phpstan-ignore-next-line */
|
||||
return new LightnCandy\SafeString($html);
|
||||
},
|
||||
@@ -636,8 +718,10 @@ class Htmlizer
|
||||
/** @phpstan-ignore-next-line */
|
||||
$paramsString = urlencode(json_encode($params));
|
||||
|
||||
/** @noinspection PhpUndefinedNamespaceInspection */
|
||||
/** @noinspection PhpUndefinedClassInspection */
|
||||
/** @phpstan-ignore-next-line */
|
||||
return new LightnCandy\SafeString("<barcodeimage data=\"{$paramsString}\"/>");
|
||||
return new LightnCandy\SafeString("<barcodeimage data=\"$paramsString\"/>");
|
||||
},
|
||||
'ifEqual' => function () {
|
||||
$args = func_get_args();
|
||||
@@ -709,7 +793,7 @@ class Htmlizer
|
||||
return null;
|
||||
}
|
||||
|
||||
$css = "font-family: zapfdingbats; color: {$color}";
|
||||
$css = "font-family: zapfdingbats; color: $color";
|
||||
|
||||
if (in_array($option, $list)) {
|
||||
$html =
|
||||
@@ -719,6 +803,8 @@ class Htmlizer
|
||||
$html = '<input type="checkbox" name="1" readonly="true" value="1" style="color: '.$css.'">';
|
||||
}
|
||||
|
||||
/** @noinspection PhpUndefinedNamespaceInspection */
|
||||
/** @noinspection PhpUndefinedClassInspection */
|
||||
/** @phpstan-ignore-next-line */
|
||||
return new LightnCandy\SafeString($html);
|
||||
},
|
||||
@@ -758,6 +844,7 @@ class Htmlizer
|
||||
|
||||
$value = $result->getValue();
|
||||
|
||||
/** @noinspection PhpFullyQualifiedNameUsageInspection */
|
||||
if ($value instanceof \Espo\Core\Htmlizer\Helper\SafeString) {
|
||||
return $value->getWrappee();
|
||||
}
|
||||
@@ -765,28 +852,26 @@ class Htmlizer
|
||||
return $value;
|
||||
};
|
||||
|
||||
if ($this->metadata) {
|
||||
$additionalHelpers = array_filter(
|
||||
$additionalHelpers = array_filter(
|
||||
$this->metadata->get(['app', 'templateHelpers']) ?? [],
|
||||
function (string $item) {
|
||||
return str_contains($item, '::');
|
||||
}
|
||||
);
|
||||
|
||||
$helpers = array_merge($helpers, $additionalHelpers);
|
||||
|
||||
$additionalHelper2NameList = array_keys(
|
||||
array_filter(
|
||||
$this->metadata->get(['app', 'templateHelpers']) ?? [],
|
||||
function (string $item) {
|
||||
return str_contains($item, '::');
|
||||
return !str_contains($item, '::');
|
||||
}
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
$helpers = array_merge($helpers, $additionalHelpers);
|
||||
|
||||
$additionalHelper2NameList = array_keys(
|
||||
array_filter(
|
||||
$this->metadata->get(['app', 'templateHelpers']) ?? [],
|
||||
function (string $item) {
|
||||
return !str_contains($item, '::');
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($additionalHelper2NameList as $name) {
|
||||
$helpers[$name] = $customHelper;
|
||||
}
|
||||
foreach ($additionalHelper2NameList as $name) {
|
||||
$helpers[$name] = $customHelper;
|
||||
}
|
||||
|
||||
return $helpers;
|
||||
@@ -794,10 +879,6 @@ class Htmlizer
|
||||
|
||||
private function getFieldType(string $entityType, string $field): ?string
|
||||
{
|
||||
if (!$this->metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
|
||||
}
|
||||
|
||||
@@ -806,10 +887,6 @@ class Htmlizer
|
||||
*/
|
||||
private function getRelationOrder(string $entityType, string $relation): array
|
||||
{
|
||||
if (!$this->entityManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$relationDefs = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entityType)
|
||||
@@ -840,14 +917,17 @@ class Htmlizer
|
||||
return [[$orderBy, $order]];
|
||||
}
|
||||
|
||||
private function handleIteration(string $template): string
|
||||
/**
|
||||
* @param string[] $helpers
|
||||
*/
|
||||
private function handleAttributeHelper(string $template, string $attribute, string $helper, array $helpers): string
|
||||
{
|
||||
if ($template === '') {
|
||||
return $template;
|
||||
}
|
||||
|
||||
if (!extension_loaded('dom')) {
|
||||
$this->log?->warning("Extension 'dom' is not enabled. HTML templating functionality is restricted.");
|
||||
$this->log->warning("Extension 'dom' is not enabled. HTML templating functionality is restricted.");
|
||||
|
||||
return $template;
|
||||
}
|
||||
@@ -857,7 +937,7 @@ class Htmlizer
|
||||
$loadResult = $xml->loadHTML($template);
|
||||
|
||||
if ($loadResult === false) {
|
||||
$this->log?->warning("HTML template parsing error.");
|
||||
$this->log->warning("HTML template parsing error.");
|
||||
|
||||
return $template;
|
||||
}
|
||||
@@ -866,7 +946,7 @@ class Htmlizer
|
||||
|
||||
$found = false;
|
||||
|
||||
$elements = $xpath->query("//*[@iterate]");
|
||||
$elements = $xpath->query("//*[@$attribute]");
|
||||
|
||||
if (!$elements) {
|
||||
return $template;
|
||||
@@ -878,13 +958,13 @@ class Htmlizer
|
||||
}
|
||||
|
||||
try {
|
||||
$wrapperElement = $xml->createElement('iteration-wrapper');
|
||||
$wrapperElement = $xml->createElement("$attribute-wrapper");
|
||||
|
||||
if (!$wrapperElement) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
$wrapperElement->setAttribute('v', $element->getAttribute('iterate'));
|
||||
$wrapperElement->setAttribute('v', $element->getAttribute($attribute));
|
||||
}
|
||||
catch (DOMException $e) {
|
||||
throw new LogicException($e->getMessage());
|
||||
@@ -902,7 +982,7 @@ class Htmlizer
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
$newElement->removeAttribute('iterate');
|
||||
$newElement->removeAttribute($attribute);
|
||||
|
||||
$wrapperElement->appendChild($newElement);
|
||||
$parentNode->replaceChild($wrapperElement, $element);
|
||||
@@ -917,25 +997,54 @@ class Htmlizer
|
||||
$newTemplate = $xml->saveXML();
|
||||
|
||||
if ($newTemplate === false || !is_string($newTemplate)) {
|
||||
$this->log?->warning("DOM save error.");
|
||||
$this->log->warning("DOM save error.");
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
$newTemplate = str_replace('</iteration-wrapper>', '{{/each}}', $newTemplate);
|
||||
$newTemplate = str_replace("</$attribute-wrapper>", "{{/$helper}}", $newTemplate);
|
||||
|
||||
$from = strpos($newTemplate,'<body>') + 6;
|
||||
$to = strrpos($newTemplate, '</body>') - strlen($newTemplate);
|
||||
|
||||
$newTemplate = substr($newTemplate, $from, $to);
|
||||
|
||||
return preg_replace('/<iteration-wrapper v="{{(.*)}}">/', '{{#each $1}}', $newTemplate) ?? '';
|
||||
$regExp = '/<' . $attribute . '-wrapper v="{{(.*?)}}">/';
|
||||
|
||||
$newTemplate = preg_replace_callback($regExp, function ($matches) use ($helpers, $helper) {
|
||||
$expression = trim($matches[1]);
|
||||
|
||||
$isHelper = false;
|
||||
|
||||
foreach ($helpers as $it) {
|
||||
if (str_starts_with($expression, $it . ' ')) {
|
||||
$isHelper = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isHelper) {
|
||||
$expression = "($expression)";
|
||||
}
|
||||
|
||||
return "{{#$helper $expression}}";
|
||||
}, $newTemplate);
|
||||
|
||||
return $newTemplate ?? '';
|
||||
}
|
||||
|
||||
private function prepare(string $template): string
|
||||
/**
|
||||
* @param string[] $helpers
|
||||
*/
|
||||
private function prepare(string $template, array $helpers): string
|
||||
{
|
||||
$template = str_replace('<tcpdf ', '', $template);
|
||||
|
||||
return $this->handleIteration($template);
|
||||
$template = $this->handleAttributeHelper($template, 'iterate', 'each', $helpers);
|
||||
/** @noinspection PhpUnnecessaryLocalVariableInspection */
|
||||
$template = $this->handleAttributeHelper($template, 'x-if', 'if', $helpers);
|
||||
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ namespace Espo\Core\Htmlizer;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\DateTime\DateTimeFactory;
|
||||
use Espo\Core\AclManager;
|
||||
|
||||
use Espo\Entities\User;
|
||||
|
||||
/**
|
||||
@@ -41,21 +40,11 @@ use Espo\Entities\User;
|
||||
*/
|
||||
class HtmlizerFactory
|
||||
{
|
||||
private $injectableFactory;
|
||||
|
||||
private $dateTimeFactory;
|
||||
|
||||
private $aclManager;
|
||||
|
||||
public function __construct(
|
||||
InjectableFactory $injectableFactory,
|
||||
DateTimeFactory $dateTimeFactory,
|
||||
AclManager $aclManager
|
||||
) {
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->dateTimeFactory = $dateTimeFactory;
|
||||
$this->aclManager = $aclManager;
|
||||
}
|
||||
private InjectableFactory $injectableFactory,
|
||||
private DateTimeFactory $dateTimeFactory,
|
||||
private AclManager $aclManager
|
||||
) {}
|
||||
|
||||
public function create(bool $skipAcl = false, ?string $timeZone = null): Htmlizer
|
||||
{
|
||||
@@ -93,6 +82,7 @@ class HtmlizerFactory
|
||||
|
||||
if ($params->applyAcl) {
|
||||
$deps['acl'] = $this->aclManager->createUserAcl($user);
|
||||
$deps['user'] = $user;
|
||||
}
|
||||
|
||||
return $this->injectableFactory->createWith(Htmlizer::class, $deps);
|
||||
|
||||
@@ -38,6 +38,8 @@ use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @phpstan-type DefaultHandlerLoaderData array{
|
||||
* className?: ?class-string<HandlerInterface>,
|
||||
* params?: ?array<string, mixed>,
|
||||
@@ -82,9 +84,10 @@ class DefaultHandlerLoader
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @param DefaultHandlerLoaderData $data
|
||||
*/
|
||||
private function loadFormatter(array $data): ?FormatterInterface
|
||||
public function loadFormatter(array $data): ?FormatterInterface
|
||||
{
|
||||
$formatterData = $data['formatter'] ?? null;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Log;
|
||||
|
||||
use Monolog\Handler\FormattableHandlerInterface;
|
||||
use Monolog\Handler\HandlerInterface;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
@@ -71,7 +72,17 @@ class HandlerListLoader
|
||||
if ($loaderClassName) {
|
||||
$loader = $this->injectableFactory->create($loaderClassName);
|
||||
|
||||
return $loader->load($params);
|
||||
$handler = $loader->load($params);
|
||||
|
||||
if ($handler instanceof FormattableHandlerInterface) {
|
||||
$formatter = $this->defaultLoader->loadFormatter($data);
|
||||
|
||||
if ($formatter) {
|
||||
$handler->setFormatter($formatter);
|
||||
}
|
||||
}
|
||||
|
||||
return $handler;
|
||||
}
|
||||
|
||||
return $this->defaultLoader->load($data, $defaultLevel);
|
||||
|
||||
@@ -155,7 +155,7 @@ class Service
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$message = $e instanceof ExceptionInterface ?
|
||||
$message = $e instanceof ExceptionInterface || $e instanceof ImapError ?
|
||||
$e->getMessage() : '';
|
||||
|
||||
throw new ErrorSilent($message);
|
||||
|
||||
@@ -31,13 +31,13 @@ namespace Espo\Core\Mail\Message\MailMimeParser;
|
||||
|
||||
use Espo\Core\Mail\Message\Part as PartInterface;
|
||||
|
||||
use ZBateson\MailMimeParser\Message\Part\MessagePart;
|
||||
use ZBateson\MailMimeParser\Message\IMessagePart;
|
||||
|
||||
class Part implements PartInterface
|
||||
{
|
||||
private MessagePart $part;
|
||||
private IMessagePart $part;
|
||||
|
||||
public function __construct(MessagePart $part)
|
||||
public function __construct(IMessagePart $part)
|
||||
{
|
||||
$this->part = $part;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ use Espo\Core\Mail\Message\MailMimeParser\Part as WrapperPart;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
use ZBateson\MailMimeParser\Header\AddressHeader;
|
||||
use ZBateson\MailMimeParser\IMessage;
|
||||
use ZBateson\MailMimeParser\MailMimeParser as WrappeeParser;
|
||||
use ZBateson\MailMimeParser\Message\Part\MessagePart;
|
||||
use ZBateson\MailMimeParser\Message\Part\MimePart;
|
||||
use ZBateson\MailMimeParser\Message as ParserMessage;
|
||||
use ZBateson\MailMimeParser\Message\MessagePart;
|
||||
use ZBateson\MailMimeParser\Message\MimePart;
|
||||
|
||||
use stdClass;
|
||||
|
||||
@@ -68,7 +68,7 @@ class MailMimeParser implements Parser
|
||||
|
||||
private const DISPOSITION_INLINE = 'inline';
|
||||
|
||||
/** @var array<string, ParserMessage> */
|
||||
/** @var array<string, IMessage> */
|
||||
private array $messageHash = [];
|
||||
|
||||
public function __construct(private EntityManager $entityManager)
|
||||
@@ -89,12 +89,11 @@ class MailMimeParser implements Parser
|
||||
|
||||
$key = spl_object_hash($message);
|
||||
|
||||
$this->messageHash[$key] = $this->getParser()->parse($raw);
|
||||
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return ParserMessage
|
||||
* @return IMessage
|
||||
*/
|
||||
private function getMessage(Message $message)
|
||||
{
|
||||
@@ -107,7 +106,7 @@ class MailMimeParser implements Parser
|
||||
$raw = $message->getFullRawContent();
|
||||
}
|
||||
|
||||
$this->messageHash[$key] = $this->getParser()->parse($raw);
|
||||
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
|
||||
}
|
||||
|
||||
return $this->messageHash[$key];
|
||||
@@ -256,7 +255,7 @@ class MailMimeParser implements Parser
|
||||
|
||||
$inlinePart = $this->getMessage($message)->getHtmlPart($i);
|
||||
|
||||
$bodyHtml .= $inlinePart->getContent();
|
||||
$bodyHtml .= $inlinePart?->getContent() ?? '';
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $textPartCount; $i++) {
|
||||
@@ -266,7 +265,7 @@ class MailMimeParser implements Parser
|
||||
|
||||
$inlinePart = $this->getMessage($message)->getTextPart($i);
|
||||
|
||||
$bodyPlain .= $inlinePart->getContent();
|
||||
$bodyPlain .= $inlinePart?->getContent() ?? '';
|
||||
}
|
||||
|
||||
if ($bodyHtml) {
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
namespace Espo\Core\MassAction\Actions;
|
||||
|
||||
use Espo\Core\Record\ActionHistory\Action;
|
||||
use Espo\Entities\ActionHistoryRecord;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
namespace Espo\Core\MassAction;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
@@ -44,7 +43,6 @@ class QueryBuilder
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function build(Params $params): Select
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ use Espo\Core\Repositories\Database as DatabaseRepository;
|
||||
use Espo\Core\Utils\ClassFinder;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity as Entity;
|
||||
use Espo\ORM\EventDispatcher;
|
||||
use Espo\ORM\Repository\Repository as Repository;
|
||||
|
||||
class ClassNameProvider
|
||||
@@ -51,8 +52,16 @@ class ClassNameProvider
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private ClassFinder $classFinder
|
||||
) {}
|
||||
private ClassFinder $classFinder,
|
||||
EventDispatcher $eventDispatcher,
|
||||
) {
|
||||
$eventDispatcher->subscribeToMetadataUpdate(function () {
|
||||
$this->entityCache = [];
|
||||
$this->repositoryCache = [];
|
||||
|
||||
$this->classFinder->resetRuntimeCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $entityType
|
||||
|
||||
40
application/Espo/Core/ORM/Defs/AttributeParam.php
Normal file
40
application/Espo/Core/ORM/Defs/AttributeParam.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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\ORM\Defs;
|
||||
|
||||
class AttributeParam
|
||||
{
|
||||
/** @internal */
|
||||
public const IS_LINK_MULTIPLE_NAME_MAP = 'isLinkMultipleNameMap';
|
||||
/** @internal */
|
||||
public const IS_LINK_MULTIPLE_ID_LIST = 'isLinkMultipleIdList';
|
||||
/** @internal */
|
||||
public const NOT_EXPORTABLE = 'notExportable';
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\ORM;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\ORM\BaseEntity;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use Espo\ORM\Type\AttributeType;
|
||||
@@ -49,7 +50,7 @@ class Entity extends BaseEntity
|
||||
{
|
||||
return
|
||||
$this->hasRelation($field) &&
|
||||
$this->getAttributeParam($field . 'Ids', 'isLinkMultipleIdList');
|
||||
$this->getAttributeParam($field . 'Ids', AttributeParam::IS_LINK_MULTIPLE_ID_LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,12 +66,18 @@ class EntityManagerFactory
|
||||
|
||||
public function create(): EntityManager
|
||||
{
|
||||
$entityFactory = $this->injectableFactory->create(EntityFactory::class);
|
||||
$entityFactory = $this->injectableFactory->createWithBinding(
|
||||
EntityFactory::class,
|
||||
BindingContainerBuilder::create()
|
||||
->bindInstance(EventDispatcher::class, $this->eventDispatcher)
|
||||
->build()
|
||||
);
|
||||
|
||||
$repositoryFactory = $this->injectableFactory->createWithBinding(
|
||||
RepositoryFactory::class,
|
||||
BindingContainerBuilder::create()
|
||||
->bindInstance(EntityFactoryInterface::class, $entityFactory)
|
||||
->bindInstance(EventDispatcher::class, $this->eventDispatcher)
|
||||
->build()
|
||||
);
|
||||
|
||||
|
||||
@@ -81,4 +81,15 @@ class SaveOption
|
||||
* Override modified-by. String.
|
||||
*/
|
||||
public const MODIFIED_BY_ID = 'modifiedById';
|
||||
/**
|
||||
* A duplicate source ID. A record that is being duplicated.
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public const DUPLICATE_SOURCE_ID = 'duplicateSourceId';
|
||||
|
||||
/**
|
||||
* When saved in Mass-Update.
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public const MASS_UPDATE = 'massUpdate';
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ class DefaultOwnershipChecker implements
|
||||
|
||||
public function checkAccount(User $user, Entity $entity): bool
|
||||
{
|
||||
/** @var string[] $accountIdList */
|
||||
$accountIdList = $user->getLinkMultipleIdList(self::FIELD_ACCOUNTS);
|
||||
|
||||
if (!count($accountIdList)) {
|
||||
|
||||
@@ -37,6 +37,7 @@ use Espo\Core\Exceptions\Error\Body as ErrorBody;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
@@ -125,6 +126,7 @@ class LinkCheck
|
||||
}
|
||||
|
||||
$attribute = $name . 'Ids';
|
||||
$namesAttribute = $name . 'Names';
|
||||
|
||||
if (
|
||||
!$entityDefs->hasAttribute($attribute) ||
|
||||
@@ -148,12 +150,6 @@ class LinkCheck
|
||||
|
||||
$this->processCheckLinkWithoutField($entityDefs, $name, $this->manyFieldTypeList);
|
||||
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$namesAttribute = $name . 'Names';
|
||||
|
||||
$names = $this->prepareNames($entity, $namesAttribute, $setIds);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
@@ -164,9 +160,7 @@ class LinkCheck
|
||||
}
|
||||
}
|
||||
|
||||
$namesAttributeDefs = $entityDefs->tryGetAttribute($namesAttribute);
|
||||
|
||||
if (!$namesAttributeDefs || !$namesAttributeDefs->getParam('isLinkMultipleNameMap')) {
|
||||
if (!$entityDefs->tryGetAttribute($namesAttribute)?->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Exceptions\NotFoundSilent;
|
||||
use Espo\Core\FieldSanitize\SanitizeManager;
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Core\Record\Access\LinkCheck;
|
||||
@@ -598,7 +599,7 @@ class Service implements Crud,
|
||||
foreach ($entityDefs->getAttributeList() as $attributeDefs) {
|
||||
if (
|
||||
$attributeDefs->getType() !== AttributeType::FOREIGN &&
|
||||
!$attributeDefs->getParam('isLinkMultipleNameMap')
|
||||
!$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -862,6 +863,7 @@ class Service implements Crud,
|
||||
$this->entityManager->saveEntity($entity, [
|
||||
SaveOption::API => true,
|
||||
SaveOption::KEEP_NEW => true,
|
||||
SaveOption::DUPLICATE_SOURCE_ID => $params->getDuplicateSourceId(),
|
||||
]);
|
||||
|
||||
$this->getRecordHookManager()->processAfterCreate($entity, $params);
|
||||
@@ -1115,10 +1117,23 @@ class Service implements Crud,
|
||||
}
|
||||
|
||||
if (!$entity->get('deleted')) {
|
||||
throw new Forbidden();
|
||||
throw new Forbidden("No 'deleted' attribute.");
|
||||
}
|
||||
|
||||
$this->getRepository()->restoreDeleted($entity->getId());
|
||||
$this->entityManager->getTransactionManager()
|
||||
->run(function () use ($entity) {
|
||||
$this->getRepository()->restoreDeleted($entity->getId());
|
||||
|
||||
if (
|
||||
$entity->hasAttribute('deleteId') &&
|
||||
$this->metadata->get("entityDefs.$this->entityType.deleteId")
|
||||
) {
|
||||
$this->entityManager->refreshEntity($entity);
|
||||
|
||||
$entity->set('deleteId', '0');
|
||||
$this->getRepository()->save($entity, [SaveOption::SILENT => true]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getMaxSelectTextAttributeLength(): ?int
|
||||
|
||||
@@ -59,10 +59,10 @@ class OnlyOwn implements Filter
|
||||
->select('id')
|
||||
->from($this->entityType)
|
||||
->leftJoin($middleEntityType, 'assignedUsersMiddle', [
|
||||
"assignedUsersMiddle.{$key1}:" => 'id',
|
||||
"assignedUsersMiddle.$key1:" => 'id',
|
||||
'assignedUsersMiddle.deleted' => false,
|
||||
])
|
||||
->where(["assignedUsersMiddle.{$key2}" => $this->user->getId()])
|
||||
->where(["assignedUsersMiddle.$key2" => $this->user->getId()])
|
||||
->build();
|
||||
|
||||
$queryBuilder->where(['id=s' => $subQuery]);
|
||||
|
||||
@@ -75,11 +75,11 @@ class OnlyTeam implements Filter
|
||||
$key2 = $relationDefs->getForeignMidKey();
|
||||
|
||||
$subQueryBuilder->leftJoin($middleEntityType, 'assignedUsersMiddle', [
|
||||
"assignedUsersMiddle.{$key1}:" => 'id',
|
||||
"assignedUsersMiddle.$key1:" => 'id',
|
||||
'assignedUsersMiddle.deleted' => false,
|
||||
]);
|
||||
|
||||
$orGroup["assignedUsersMiddle.{$key2}"] = $this->user->getId();
|
||||
$orGroup["assignedUsersMiddle.$key2"] = $this->user->getId();
|
||||
}
|
||||
else if ($this->fieldHelper->hasAssignedUserField()) {
|
||||
$orGroup['assignedUserId'] = $this->user->getId();
|
||||
|
||||
@@ -132,7 +132,7 @@ class DefaultDateTimeItemTransformer implements DateTimeItemTransformer
|
||||
|
||||
break;
|
||||
|
||||
case 'lastSevenDays':
|
||||
case Type::LAST_SEVEN_DAYS:
|
||||
$where['type'] = Type::BETWEEN;
|
||||
|
||||
$dtFrom = clone $dt;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
101
application/Espo/Core/Upgrades/Migrations/V8_4/AfterUpgrade.php
Normal file
101
application/Espo/Core/Upgrades/Migrations/V8_4/AfterUpgrade.php
Normal 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\Core\Upgrades\Migrations\V8_4;
|
||||
|
||||
use Espo\Core\Upgrades\Migration\Script;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
|
||||
class AfterUpgrade implements Script
|
||||
{
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->updateMetadata();
|
||||
}
|
||||
|
||||
private function updateMetadata(): void
|
||||
{
|
||||
$defs = $this->metadata->get(['entityDefs']);
|
||||
|
||||
$toSave = false;
|
||||
|
||||
foreach ($defs as $entityType => $item) {
|
||||
if (!isset($item['links'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($item['links'] as $link => $linkDefs) {
|
||||
$type = $linkDefs['type'] ?? null;
|
||||
$foreignEntityType = $linkDefs['entity'] ?? null;
|
||||
$midKeys = $linkDefs['midKeys'] ?? null;
|
||||
$isCustom = $linkDefs['isCustom'] ?? false;
|
||||
|
||||
if ($type !== RelationType::HAS_MANY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($foreignEntityType !== $entityType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$midKeys) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$isCustom) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($linkDefs['_keysSwappedAfterUpgrade'] ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->metadata->set('entityDefs', $entityType, [
|
||||
'links' => [
|
||||
$link => [
|
||||
'midKeys' => array_reverse($midKeys),
|
||||
'_keysSwappedAfterUpgrade' => true,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$toSave = true;
|
||||
}
|
||||
|
||||
if ($toSave) {
|
||||
$this->metadata->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,17 @@ class ClassFinder
|
||||
public function __construct(private ClassMap $classMap)
|
||||
{}
|
||||
|
||||
/**
|
||||
* Reset runtime cache.
|
||||
*
|
||||
* @internal
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public function resetRuntimeCache(): void
|
||||
{
|
||||
$this->dataHashMap = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find class name by a category and name.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace Espo\Core\Utils\Database\Orm;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\Utils\Database\ConfigDataProvider;
|
||||
use Espo\Core\Utils\Database\MetadataProvider;
|
||||
use Espo\Core\Utils\Util;
|
||||
@@ -70,7 +71,7 @@ class Converter
|
||||
'maxLength' => 'len',
|
||||
'len' => 'len',
|
||||
'notNull' => 'notNull',
|
||||
'exportDisabled' => 'notExportable',
|
||||
'exportDisabled' => AttributeParam::NOT_EXPORTABLE,
|
||||
'autoincrement' => 'autoincrement',
|
||||
'entity' => 'entity',
|
||||
'notStorable' => 'notStorable',
|
||||
@@ -502,19 +503,19 @@ class Converter
|
||||
$ormMetadata[$entityType]['attributes']['isFollowed'] = [
|
||||
'type' => Entity::BOOL,
|
||||
'notStorable' => true,
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
];
|
||||
|
||||
$ormMetadata[$entityType]['attributes']['followersIds'] = [
|
||||
'type' => Entity::JSON_ARRAY,
|
||||
'notStorable' => true,
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
];
|
||||
|
||||
$ormMetadata[$entityType]['attributes']['followersNames'] = [
|
||||
'type' => Entity::JSON_OBJECT,
|
||||
'notStorable' => true,
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -525,7 +526,7 @@ class Converter
|
||||
$ormMetadata[$entityType]['attributes']['isStarred'] = [
|
||||
'type' => Entity::BOOL,
|
||||
'notStorable' => true,
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
'readOnly' => true,
|
||||
];
|
||||
}
|
||||
@@ -536,7 +537,7 @@ class Converter
|
||||
$ormMetadata[$entityType]['attributes']['versionNumber'] = [
|
||||
'type' => Entity::INT,
|
||||
'dbType' => Types::BIGINT,
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\FieldConverter;
|
||||
@@ -52,7 +53,7 @@ class AttachmentMultiple implements FieldConverter
|
||||
['createdAt', Order::ASC],
|
||||
['name', Order::ASC],
|
||||
],
|
||||
'isLinkMultipleIdList' => true,
|
||||
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
|
||||
'relation' => $name,
|
||||
])
|
||||
)
|
||||
@@ -61,7 +62,7 @@ class AttachmentMultiple implements FieldConverter
|
||||
->withType(AttributeType::JSON_OBJECT)
|
||||
->withNotStorable()
|
||||
->withParamsMerged([
|
||||
'isLinkMultipleNameMap' => true,
|
||||
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
|
||||
@@ -61,7 +62,7 @@ class Email implements FieldConverter
|
||||
->withType(AttributeType::JSON_ARRAY)
|
||||
->withNotStorable()
|
||||
->withParamsMerged([
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
'isEmailAddressData' => true,
|
||||
'field' => $name,
|
||||
]);
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\FieldConverter;
|
||||
@@ -49,7 +51,7 @@ class LinkMultiple implements FieldConverter
|
||||
->withType(AttributeType::JSON_ARRAY)
|
||||
->withNotStorable()
|
||||
->withParamsMerged([
|
||||
'isLinkMultipleIdList' => true,
|
||||
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
|
||||
'relation' => $name,
|
||||
'isUnordered' => true,
|
||||
'attributeRole' => 'idList',
|
||||
@@ -67,9 +69,9 @@ class LinkMultiple implements FieldConverter
|
||||
->withType(AttributeType::JSON_OBJECT)
|
||||
->withNotStorable()
|
||||
->withParamsMerged([
|
||||
'isLinkMultipleNameMap' => true,
|
||||
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
|
||||
'attributeRole' => 'nameMap',
|
||||
'fieldType' => 'linkMultiple',
|
||||
'fieldType' => FieldType::LINK_MULTIPLE,
|
||||
]);
|
||||
|
||||
$orderBy = $fieldDefs->getParam('orderBy');
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
|
||||
|
||||
use Espo\Core\ORM\Defs\AttributeParam;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
|
||||
@@ -64,7 +65,7 @@ class Phone implements FieldConverter
|
||||
->withType(AttributeType::JSON_ARRAY)
|
||||
->withNotStorable()
|
||||
->withParamsMerged([
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
'isPhoneNumberData' => true,
|
||||
'field' => $name,
|
||||
]);
|
||||
@@ -389,7 +390,7 @@ class Phone implements FieldConverter
|
||||
private function getNumericParams(string $entityType): array
|
||||
{
|
||||
return [
|
||||
'notExportable' => true,
|
||||
AttributeParam::NOT_EXPORTABLE => true,
|
||||
'where' => [
|
||||
'LIKE' => [
|
||||
'whereClause' => [
|
||||
|
||||
@@ -52,13 +52,18 @@ class ManyMany implements LinkConverter
|
||||
$linkDefs->getRelationshipName() :
|
||||
self::composeRelationshipName($entityType, $foreignEntityType);
|
||||
|
||||
$key1 = lcfirst($entityType) . 'Id';
|
||||
$key2 = lcfirst($foreignEntityType) . 'Id';
|
||||
if ($linkDefs->hasMidKey() && $linkDefs->hasForeignMidKey()) {
|
||||
$key1 = $linkDefs->getMidKey();
|
||||
$key2 = $linkDefs->getForeignMidKey();
|
||||
} else {
|
||||
$key1 = lcfirst($entityType) . 'Id';
|
||||
$key2 = lcfirst($foreignEntityType) . 'Id';
|
||||
|
||||
if ($key1 === $key2) {
|
||||
[$key1, $key2] = strcmp($name, $foreignRelationName) ?
|
||||
['leftId', 'rightId'] :
|
||||
['rightId', 'leftId'];
|
||||
if ($key1 === $key2) {
|
||||
[$key1, $key2] = strcmp($name, $foreignRelationName) > 0 ?
|
||||
['leftId', 'rightId'] :
|
||||
['rightId', 'leftId'];
|
||||
}
|
||||
}
|
||||
|
||||
$relationDefs = RelationDefs::create($name)
|
||||
|
||||
@@ -50,11 +50,10 @@ class RelationConverter
|
||||
private const DEFAULT_VARCHAR_LENGTH = 255;
|
||||
|
||||
/** @var string[] */
|
||||
private $allowedParams = [
|
||||
private $mergeParams = [
|
||||
'relationName',
|
||||
'conditions',
|
||||
'additionalColumns',
|
||||
'midKeys',
|
||||
'noJoin',
|
||||
'indexes',
|
||||
];
|
||||
@@ -111,7 +110,7 @@ class RelationConverter
|
||||
$raw = $convertedEntityDefs->toAssoc();
|
||||
|
||||
if (isset($raw['relations'][$name])) {
|
||||
$this->mergeAllowedParams($raw['relations'][$name], $params, $foreignParams ?? []);
|
||||
$this->mergeParams($raw['relations'][$name], $params, $foreignParams ?? []);
|
||||
$this->correct($raw['relations'][$name]);
|
||||
}
|
||||
|
||||
@@ -171,10 +170,10 @@ class RelationConverter
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $foreignParams
|
||||
*/
|
||||
private function mergeAllowedParams(array &$relationDefs, array $params, array $foreignParams): void
|
||||
private function mergeParams(array &$relationDefs, array $params, array $foreignParams): void
|
||||
{
|
||||
foreach ($this->allowedParams as $name) {
|
||||
$additionalParam = $this->getAllowedParam($name, $params, $foreignParams);
|
||||
foreach ($this->mergeParams as $name) {
|
||||
$additionalParam = $this->getMergedParam($name, $params, $foreignParams);
|
||||
|
||||
if ($additionalParam === null) {
|
||||
continue;
|
||||
@@ -189,7 +188,7 @@ class RelationConverter
|
||||
* @param array<string, mixed> $foreignParams
|
||||
* @return array<string, mixed>|scalar|null
|
||||
*/
|
||||
private function getAllowedParam(string $name, array $params, array $foreignParams): mixed
|
||||
private function getMergedParam(string $name, array $params, array $foreignParams): mixed
|
||||
{
|
||||
$value = $params[$name] ?? null;
|
||||
$foreignValue = $foreignParams[$name] ?? null;
|
||||
|
||||
44
application/Espo/Core/Utils/Metadata/AdditionalBuilder.php
Normal file
44
application/Espo/Core/Utils/Metadata/AdditionalBuilder.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?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\Utils\Metadata;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* An additional metadata builder. Allows adding conditional metadata.
|
||||
* Warning: Dependency injection is not available here. Instantiate
|
||||
* needed classes explicitly.
|
||||
*
|
||||
* @since 8.4.0
|
||||
*/
|
||||
interface AdditionalBuilder
|
||||
{
|
||||
public function build(stdClass $data): void;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
@@ -26,29 +27,35 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
define('crm:views/call/fields/date-end', ['views/fields/datetime'], function (Dep) {
|
||||
namespace Espo\Core\Utils\Metadata\AdditionalBuilder;
|
||||
|
||||
return Dep.extend({
|
||||
use Espo\Core\Utils\Metadata\AdditionalBuilder;
|
||||
use stdClass;
|
||||
|
||||
validateAfter: function () {
|
||||
var field = this.model.getFieldParam(this.name, 'after');
|
||||
class DeleteIdField implements AdditionalBuilder
|
||||
{
|
||||
public function build(stdClass $data): void
|
||||
{
|
||||
if (!isset($data->entityDefs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field) {
|
||||
var value = this.model.get(this.name);
|
||||
var otherValue = this.model.get(field);
|
||||
|
||||
if (value && otherValue) {
|
||||
if (moment(value).unix() < moment(otherValue).unix()) {
|
||||
var msg = this.translate('fieldShouldAfter', 'messages')
|
||||
.replace('{field}', this.getLabelText())
|
||||
.replace('{otherField}', this.translate(field, 'fields', this.entityType));
|
||||
|
||||
this.showValidationMessage(msg);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
|
||||
if (!($entityDefsItem->deleteId ?? false)) {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$entityDefsItem->fields ??= (object) [];
|
||||
|
||||
$data->entityDefs->$entityType->fields->deleteId = (object) [
|
||||
"type" => "varchar",
|
||||
"maxLength" => 17,
|
||||
"readOnly" => true,
|
||||
"notNull" => true,
|
||||
"default" => "0",
|
||||
"utility" => true,
|
||||
"customizationDisabled" => true
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?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\Utils\Metadata\AdditionalBuilder;
|
||||
|
||||
use Espo\Core\Utils\DataUtil;
|
||||
use Espo\Core\Utils\Metadata\AdditionalBuilder;
|
||||
use Espo\Core\Utils\Metadata\BuilderHelper;
|
||||
use Espo\Core\Utils\Util;
|
||||
use stdClass;
|
||||
|
||||
class Fields implements AdditionalBuilder
|
||||
{
|
||||
private BuilderHelper $builderHelper;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->builderHelper = new BuilderHelper();
|
||||
}
|
||||
|
||||
public function build(stdClass $data): void
|
||||
{
|
||||
if (!isset($data->entityDefs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldDefinitionList = Util::objectToArray($data->fields);
|
||||
|
||||
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
|
||||
if (isset($data->entityDefs->$entityType->collection)) {
|
||||
/** @var stdClass $collectionItem */
|
||||
$collectionItem = $data->entityDefs->$entityType->collection;
|
||||
|
||||
if (isset($collectionItem->orderBy)) {
|
||||
$collectionItem->sortBy = $collectionItem->orderBy;
|
||||
}
|
||||
else if (isset($collectionItem->sortBy)) {
|
||||
$collectionItem->orderBy = $collectionItem->sortBy;
|
||||
}
|
||||
|
||||
if (isset($collectionItem->order)) {
|
||||
$collectionItem->asc = $collectionItem->order === 'asc';
|
||||
}
|
||||
else if (isset($collectionItem->asc)) {
|
||||
$collectionItem->order = $collectionItem->asc === true ? 'asc' : 'desc';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($entityDefsItem->fields)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
|
||||
$additionalFields = $this->builderHelper->getAdditionalFieldList(
|
||||
$field,
|
||||
Util::objectToArray($fieldDefsItem),
|
||||
$fieldDefinitionList
|
||||
);
|
||||
|
||||
if (!$additionalFields) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($additionalFields as $subFieldName => $subFieldParams) {
|
||||
$item = Util::arrayToObject($subFieldParams);
|
||||
|
||||
if (isset($entityDefsItem->fields->$subFieldName)) {
|
||||
$data->entityDefs->$entityType->fields->$subFieldName =
|
||||
DataUtil::merge(
|
||||
$item,
|
||||
$entityDefsItem->fields->$subFieldName
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data->entityDefs->$entityType->fields->$subFieldName = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
namespace Espo\Core\Utils\Metadata;
|
||||
|
||||
use Espo\Core\Utils\DataUtil;
|
||||
use Espo\Core\Utils\Resource\Reader as ResourceReader;
|
||||
use Espo\Core\Utils\Resource\Reader\Params as ResourceReaderParams;
|
||||
use Espo\Core\Utils\Util;
|
||||
@@ -39,6 +38,7 @@ class Builder
|
||||
{
|
||||
/** @var array<int, string[]> */
|
||||
private $forceAppendPathList = [
|
||||
['app', 'metadata', 'additionalBuilderClassNameList'],
|
||||
['app', 'rebuild', 'actionClassNameList'],
|
||||
['app', 'formula', 'functionList'],
|
||||
['app', 'fieldProcessing', 'readLoaderClassNameList'],
|
||||
@@ -76,10 +76,7 @@ class Builder
|
||||
|
||||
private const ANY_KEY = '__ANY__';
|
||||
|
||||
public function __construct(
|
||||
private ResourceReader $resourceReader,
|
||||
private BuilderHelper $builderHelper
|
||||
) {}
|
||||
public function __construct(private ResourceReader $resourceReader) {}
|
||||
|
||||
public function build(): stdClass
|
||||
{
|
||||
@@ -88,109 +85,24 @@ class Builder
|
||||
|
||||
$data = $this->resourceReader->read('metadata', $readerParams);
|
||||
|
||||
$this->addAdditionalField($data);
|
||||
$this->applyAdditional($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function addAdditionalField(stdClass $data): void
|
||||
private function applyAdditional(stdClass $data): void
|
||||
{
|
||||
if (!isset($data->entityDefs)) {
|
||||
return;
|
||||
}
|
||||
/** @var class-string<AdditionalBuilder>[] $builderClassNameList */
|
||||
$builderClassNameList = Util::getValueByKey($data, 'app.metadata.additionalBuilderClassNameList') ?? [];
|
||||
|
||||
$fieldDefinitionList = Util::objectToArray($data->fields);
|
||||
/** @var AdditionalBuilder[] $builderList */
|
||||
$builderList = array_map(
|
||||
fn ($className) => new $className(),
|
||||
$builderClassNameList
|
||||
);
|
||||
|
||||
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
|
||||
if (isset($data->entityDefs->$entityType->collection)) {
|
||||
/** @var stdClass $collectionItem */
|
||||
$collectionItem = $data->entityDefs->$entityType->collection;
|
||||
|
||||
if (isset($collectionItem->orderBy)) {
|
||||
$collectionItem->sortBy = $collectionItem->orderBy;
|
||||
}
|
||||
else if (isset($collectionItem->sortBy)) {
|
||||
$collectionItem->orderBy = $collectionItem->sortBy;
|
||||
}
|
||||
|
||||
if (isset($collectionItem->order)) {
|
||||
$collectionItem->asc = $collectionItem->order === 'asc';
|
||||
}
|
||||
else if (isset($collectionItem->asc)) {
|
||||
$collectionItem->order = $collectionItem->asc === true ? 'asc' : 'desc';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($entityDefsItem->fields)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
|
||||
$additionalFields = $this->builderHelper->getAdditionalFieldList(
|
||||
$field,
|
||||
Util::objectToArray($fieldDefsItem),
|
||||
$fieldDefinitionList
|
||||
);
|
||||
|
||||
if (!$additionalFields) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($additionalFields as $subFieldName => $subFieldParams) {
|
||||
$item = Util::arrayToObject($subFieldParams);
|
||||
|
||||
if (isset($entityDefsItem->fields->$subFieldName)) {
|
||||
$data->entityDefs->$entityType->fields->$subFieldName =
|
||||
DataUtil::merge(
|
||||
$item,
|
||||
$entityDefsItem->fields->$subFieldName
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data->entityDefs->$entityType->fields->$subFieldName = $item;
|
||||
}
|
||||
}
|
||||
foreach ($builderList as $builder) {
|
||||
$builder->build($data);
|
||||
}
|
||||
}
|
||||
|
||||
/*private function setMissingFieldDefaults(stdClass $data): void
|
||||
{
|
||||
if (!isset($data->entityDefs) || !isset($data->fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (get_object_vars($data->entityDefs) as $entityDefsItem) {
|
||||
if (!isset($entityDefsItem->fields)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefs) {
|
||||
$oFieldDefs = FieldDefs::fromRaw(Util::objectToArray($fieldDefs), $field);
|
||||
|
||||
$type = $oFieldDefs->getType();
|
||||
|
||||
$typeDefs = $data->fields->$type ?? null;
|
||||
|
||||
if (!$typeDefs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!property_exists($typeDefs, 'default')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
$oFieldDefs->getParam('utility') ||
|
||||
$oFieldDefs->getParam('disabled') ||
|
||||
$oFieldDefs->hasParam('default')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldDefs->default = $typeDefs->default;
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -29,8 +29,12 @@
|
||||
|
||||
namespace Espo\Core\WebSocket;
|
||||
|
||||
use GuzzleHttp\Psr7\Query;
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Ratchet\ConnectionInterface;
|
||||
use Ratchet\Wamp\Topic;
|
||||
use Ratchet\Wamp\WampConnection;
|
||||
use Ratchet\Wamp\WampServerInterface;
|
||||
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
@@ -88,7 +92,7 @@ class Pusher implements WampServerInterface
|
||||
* @param Topic<object> $topic
|
||||
* @return void
|
||||
*/
|
||||
public function onSubscribe(ConnectionInterface $connection, $topic)
|
||||
public function onSubscribe(ConnectionInterface $conn, $topic)
|
||||
{
|
||||
$topicId = $topic->getId();
|
||||
|
||||
@@ -101,10 +105,9 @@ class Pusher implements WampServerInterface
|
||||
}
|
||||
|
||||
/** @var string $connectionId */
|
||||
/** @phpstan-ignore-next-line */
|
||||
$connectionId = $connection->resourceId;
|
||||
$connectionId = $conn->resourceId ?? throw new RuntimeException();
|
||||
|
||||
$userId = $this->getUserIdByConnection($connection);
|
||||
$userId = $this->getUserIdByConnection($conn);
|
||||
|
||||
if (!$userId) {
|
||||
return;
|
||||
@@ -114,27 +117,27 @@ class Pusher implements WampServerInterface
|
||||
$this->connectionIdTopicIdListMap[$connectionId] = [];
|
||||
}
|
||||
|
||||
$checkCommand = $this->getAccessCheckCommandForTopic($connection, $topic);
|
||||
$checkCommand = $this->getAccessCheckCommandForTopic($conn, $topic);
|
||||
|
||||
if ($checkCommand) {
|
||||
$checkResult = shell_exec($checkCommand);
|
||||
|
||||
if ($checkResult !== 'true') {
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$connectionId}: check access failed for topic {$topicId} for user {$userId}");
|
||||
$this->log("$connectionId: check access failed for topic $topicId for user $userId");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$connectionId}: check access succeed for topic {$topicId} for user {$userId}");
|
||||
$this->log("$connectionId: check access succeed for topic $topicId for user $userId");
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$connectionId}: add topic {$topicId} for user {$userId}");
|
||||
$this->log("$connectionId: add topic $topicId for user $userId");
|
||||
}
|
||||
|
||||
$this->connectionIdTopicIdListMap[$connectionId][] = $topicId;
|
||||
@@ -147,7 +150,7 @@ class Pusher implements WampServerInterface
|
||||
* @param Topic<object> $topic
|
||||
* @return void
|
||||
*/
|
||||
public function onUnSubscribe(ConnectionInterface $connection, $topic)
|
||||
public function onUnSubscribe(ConnectionInterface $conn, $topic)
|
||||
{
|
||||
$topicId = $topic->getId();
|
||||
|
||||
@@ -160,36 +163,38 @@ class Pusher implements WampServerInterface
|
||||
}
|
||||
|
||||
/** @var string $connectionId */
|
||||
/** @phpstan-ignore-next-line */
|
||||
$connectionId = $connection->resourceId;
|
||||
$connectionId = $conn->resourceId ?? throw new RuntimeException();
|
||||
|
||||
$userId = $this->getUserIdByConnection($connection);
|
||||
$userId = $this->getUserIdByConnection($conn);
|
||||
|
||||
if (!$userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($this->connectionIdTopicIdListMap[$connectionId])) {
|
||||
$index = array_search($topicId, $this->connectionIdTopicIdListMap[$connectionId]);
|
||||
|
||||
if ($index !== false) {
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$connectionId}: remove topic {$topicId} for user {$userId}");
|
||||
}
|
||||
|
||||
unset($this->connectionIdTopicIdListMap[$connectionId][$index]);
|
||||
|
||||
$this->connectionIdTopicIdListMap[$connectionId] = array_values(
|
||||
$this->connectionIdTopicIdListMap[$connectionId]
|
||||
);
|
||||
}
|
||||
if (!isset($this->connectionIdTopicIdListMap[$connectionId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$index = array_search($topicId, $this->connectionIdTopicIdListMap[$connectionId]);
|
||||
|
||||
if ($index === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("$connectionId: remove topic $topicId for user $userId");
|
||||
}
|
||||
|
||||
unset($this->connectionIdTopicIdListMap[$connectionId][$index]);
|
||||
|
||||
$this->connectionIdTopicIdListMap[$connectionId] =
|
||||
array_values($this->connectionIdTopicIdListMap[$connectionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getCategoryData(string $topicId): array
|
||||
private function getCategoryData(string $topicId): array
|
||||
{
|
||||
$arr = explode('.', $topicId);
|
||||
|
||||
@@ -197,11 +202,9 @@ class Pusher implements WampServerInterface
|
||||
|
||||
if (array_key_exists($category, $this->categoriesData)) {
|
||||
$data = $this->categoriesData[$category];
|
||||
}
|
||||
else if (array_key_exists($topicId, $this->categoriesData)) {
|
||||
} else if (array_key_exists($topicId, $this->categoriesData)) {
|
||||
$data = $this->categoriesData[$topicId];
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ class Pusher implements WampServerInterface
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getParamsFromTopicId(string $topicId): array
|
||||
private function getParamsFromTopicId(string $topicId): array
|
||||
{
|
||||
$arr = explode('.', $topicId);
|
||||
|
||||
@@ -222,10 +225,10 @@ class Pusher implements WampServerInterface
|
||||
if (array_key_exists('paramList', $data)) {
|
||||
foreach ($data['paramList'] as $i => $item) {
|
||||
/** @var string $item */
|
||||
|
||||
if (isset($arr[$i + 1])) {
|
||||
$params[$item] = $arr[$i + 1];
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$params[$item] = '';
|
||||
}
|
||||
}
|
||||
@@ -237,15 +240,15 @@ class Pusher implements WampServerInterface
|
||||
/**
|
||||
* @param Topic<object> $topic
|
||||
*/
|
||||
protected function getAccessCheckCommandForTopic(ConnectionInterface $connection, $topic): ?string
|
||||
private function getAccessCheckCommandForTopic(ConnectionInterface $conn, $topic): ?string
|
||||
{
|
||||
$topicId = $topic->getId();
|
||||
|
||||
$params = $this->getParamsFromTopicId($topicId);
|
||||
$params['userId'] = $this->getUserIdByConnection($connection);
|
||||
$params['userId'] = $this->getUserIdByConnection($conn);
|
||||
|
||||
if (!$params['userId']) {
|
||||
$connection->close();
|
||||
$conn->close();
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -269,24 +272,13 @@ class Pusher implements WampServerInterface
|
||||
return $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Topic<object> $topic
|
||||
* @return string
|
||||
*/
|
||||
protected function getTopicCategory($topic)
|
||||
{
|
||||
list($category) = explode('.', $topic->getId());
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $topicId
|
||||
* @return bool
|
||||
*/
|
||||
protected function isTopicAllowed($topicId)
|
||||
private function isTopicAllowed($topicId)
|
||||
{
|
||||
list($category) = explode('.', $topicId);
|
||||
[$category] = explode('.', $topicId);
|
||||
|
||||
return in_array($topicId, $this->categoryList) || in_array($category, $this->categoryList);
|
||||
}
|
||||
@@ -295,7 +287,7 @@ class Pusher implements WampServerInterface
|
||||
* @param string $userId
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getConnectionIdListByUserId($userId)
|
||||
private function getConnectionIdListByUserId($userId)
|
||||
{
|
||||
if (!isset($this->userIdConnectionIdListMap[$userId])) {
|
||||
return [];
|
||||
@@ -307,26 +299,25 @@ class Pusher implements WampServerInterface
|
||||
/**
|
||||
* @return ?string
|
||||
*/
|
||||
protected function getUserIdByConnection(ConnectionInterface $connection)
|
||||
private function getUserIdByConnection(ConnectionInterface $conn)
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!isset($this->connectionIdUserIdMap[$connection->resourceId])) {
|
||||
$connectionId = $conn->resourceId ?? '';
|
||||
|
||||
if (!isset($this->connectionIdUserIdMap[$connectionId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
return $this->connectionIdUserIdMap[$connection->resourceId];
|
||||
return $this->connectionIdUserIdMap[$connectionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return void
|
||||
*/
|
||||
protected function subscribeUser(ConnectionInterface $connection, $userId)
|
||||
private function subscribeUser(ConnectionInterface $conn, $userId)
|
||||
{
|
||||
/** @var string $resourceId */
|
||||
/** @phpstan-ignore-next-line */
|
||||
$resourceId = $connection->resourceId;
|
||||
$resourceId = $conn->resourceId ?? '';
|
||||
|
||||
$this->connectionIdUserIdMap[$resourceId] = $userId;
|
||||
|
||||
@@ -338,10 +329,10 @@ class Pusher implements WampServerInterface
|
||||
$this->userIdConnectionIdListMap[$userId][] = $resourceId;
|
||||
}
|
||||
|
||||
$this->connections[$resourceId] = $connection;
|
||||
$this->connections[$resourceId] = $conn;
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$resourceId}: user {$userId} subscribed");
|
||||
$this->log("$resourceId: user $userId subscribed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,10 +340,9 @@ class Pusher implements WampServerInterface
|
||||
* @param string $userId
|
||||
* @return void
|
||||
*/
|
||||
protected function unsubscribeUser(ConnectionInterface $connection, $userId)
|
||||
private function unsubscribeUser(ConnectionInterface $conn, $userId)
|
||||
{
|
||||
/** @phpstan-ignore-next-line */
|
||||
$resourceId = $connection->resourceId;
|
||||
$resourceId = $conn->resourceId ?? '';
|
||||
|
||||
unset($this->connectionIdUserIdMap[$resourceId]);
|
||||
|
||||
@@ -366,51 +356,52 @@ class Pusher implements WampServerInterface
|
||||
}
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("{$resourceId}: user {$userId} unsubscribed");
|
||||
$this->log("$resourceId: user $userId unsubscribed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function onOpen(ConnectionInterface $connection)
|
||||
public function onOpen(ConnectionInterface $conn)
|
||||
{
|
||||
if ($this->isDebugMode) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$this->log("{$connection->resourceId}: open");
|
||||
$resourceId = $conn->resourceId ?? '';
|
||||
|
||||
$this->log("$resourceId: open");
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$httpRequest = $connection->httpRequest;
|
||||
/** @var RequestInterface $httpRequest */
|
||||
$httpRequest = $conn->httpRequest ?? throw new RuntimeException();
|
||||
|
||||
$query = $httpRequest->getUri()->getQuery();
|
||||
|
||||
$params = \GuzzleHttp\Psr7\parse_query($query ?: '');
|
||||
$params = Query::parse($query ?: '');
|
||||
|
||||
if (empty($params['userId']) || empty($params['authToken'])) {
|
||||
$this->closeConnection($connection);
|
||||
$this->closeConnection($conn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$authToken = preg_replace('/[^a-zA-Z0-9]+/', '', $params['authToken']);
|
||||
$userId = preg_replace('/[^a-zA-Z0-9]+/', '', $params['userId']);
|
||||
$authToken = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['authToken']);
|
||||
$userId = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['userId']);
|
||||
|
||||
$result = $this->getUserIdByAuthToken($authToken);
|
||||
|
||||
if (empty($result)) {
|
||||
$this->closeConnection($connection);
|
||||
$this->closeConnection($conn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result !== $userId) {
|
||||
$this->closeConnection($connection);
|
||||
$this->closeConnection($conn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->subscribeUser($connection, $userId);
|
||||
$this->subscribeUser($conn, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,35 +423,35 @@ class Pusher implements WampServerInterface
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function closeConnection(ConnectionInterface $connection)
|
||||
private function closeConnection(ConnectionInterface $conn)
|
||||
{
|
||||
$userId = $this->getUserIdByConnection($connection);
|
||||
$userId = $this->getUserIdByConnection($conn);
|
||||
|
||||
if ($userId) {
|
||||
$this->unsubscribeUser($connection, $userId);
|
||||
$this->unsubscribeUser($conn, $userId);
|
||||
}
|
||||
|
||||
$connection->close();
|
||||
$conn->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function onClose(ConnectionInterface $connection)
|
||||
public function onClose(ConnectionInterface $conn)
|
||||
{
|
||||
$connectionId = $conn->resourceId ?? '';
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$this->log("{$connection->resourceId}: close");
|
||||
$this->log("$connectionId: close");
|
||||
}
|
||||
|
||||
$userId = $this->getUserIdByConnection($connection);
|
||||
$userId = $this->getUserIdByConnection($conn);
|
||||
|
||||
if ($userId) {
|
||||
$this->unsubscribeUser($connection, $userId);
|
||||
$this->unsubscribeUser($conn, $userId);
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
unset($this->connections[$connection->resourceId]);
|
||||
unset($this->connections[$connectionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -469,25 +460,24 @@ class Pusher implements WampServerInterface
|
||||
* @param array<string, mixed> $params
|
||||
* @return void
|
||||
*/
|
||||
public function onCall(ConnectionInterface $connection, $id, $topic, array $params)
|
||||
public function onCall(ConnectionInterface $conn, $id, $topic, array $params)
|
||||
{
|
||||
if (!method_exists($connection, 'callError')) {
|
||||
if (!method_exists($conn, 'callError')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$connection
|
||||
->callError($id, $topic, 'You are not allowed to make calls')
|
||||
$conn->callError($id, $topic, 'You are not allowed to make calls')
|
||||
->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Topic<object> $topic
|
||||
* @param string $event
|
||||
* @param array<mixed, mixed> $exclude
|
||||
* @param array<mixed, mixed> $eligible
|
||||
* @param array<int|string, mixed> $exclude
|
||||
* @param array<int|string, mixed> $eligible
|
||||
* @return void
|
||||
*/
|
||||
public function onPublish(ConnectionInterface $connection, $topic, $event, array $exclude, array $eligible)
|
||||
public function onPublish(ConnectionInterface $conn, $topic, $event, array $exclude, array $eligible)
|
||||
{
|
||||
$topicId = $topic->getId();
|
||||
|
||||
@@ -495,13 +485,13 @@ class Pusher implements WampServerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$connection->close();
|
||||
$conn->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function onError(ConnectionInterface $connection, Exception $e)
|
||||
public function onError(ConnectionInterface $conn, Exception $e)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -534,12 +524,12 @@ class Pusher implements WampServerInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var \Ratchet\Wamp\WampConnection $connection */
|
||||
/** @var WampConnection $connection */
|
||||
$connection = $this->connections[$connectionId];
|
||||
|
||||
if (in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("send {$topicId} for connection {$connectionId}");
|
||||
$this->log("send $topicId for connection $connectionId");
|
||||
}
|
||||
|
||||
$connection->event($topicId, $data);
|
||||
@@ -547,7 +537,7 @@ class Pusher implements WampServerInterface
|
||||
}
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("message {$topicId} for user {$userId}");
|
||||
$this->log("message $topicId for user $userId");
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -559,16 +549,16 @@ class Pusher implements WampServerInterface
|
||||
$topic->broadcast($data);
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("send {$topicId} to all");
|
||||
$this->log("send $topicId to all");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isDebugMode) {
|
||||
$this->log("message {$topicId} for all");
|
||||
$this->log("message $topicId for all");
|
||||
}
|
||||
}
|
||||
|
||||
protected function log(string $msg): void
|
||||
private function log(string $msg): void
|
||||
{
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $msg . "\n";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ use Espo\Core\Utils\Json;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @todo Introduce a wrapper class that will skip sending if WebSocket is not enabled.
|
||||
*/
|
||||
class Submission
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Webhook;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Webhook;
|
||||
use Espo\Entities\WebhookEventQueueItem;
|
||||
@@ -41,7 +42,6 @@ use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
|
||||
use Exception;
|
||||
use DateTime;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
@@ -74,11 +74,10 @@ class Queue
|
||||
{
|
||||
$portionSize = $this->config->get('webhookQueueEventPortionSize', self::EVENT_PORTION_SIZE);
|
||||
|
||||
/** @var iterable<WebhookEventQueueItem> $itemList */
|
||||
$itemList = $this->entityManager
|
||||
->getRDBRepository(WebhookEventQueueItem::ENTITY_TYPE)
|
||||
->where([
|
||||
'isProcessed' => false,
|
||||
])
|
||||
->where(['isProcessed' => false])
|
||||
->order('number')
|
||||
->limit(0, $portionSize)
|
||||
->find();
|
||||
@@ -86,10 +85,7 @@ class Queue
|
||||
foreach ($itemList as $item) {
|
||||
$this->createQueueFromEvent($item);
|
||||
|
||||
$item->set([
|
||||
'isProcessed' => true,
|
||||
]);
|
||||
|
||||
$item->setIsProcessed();
|
||||
$this->entityManager->saveEntity($item);
|
||||
}
|
||||
}
|
||||
@@ -186,8 +182,7 @@ class Queue
|
||||
$user = null;
|
||||
|
||||
if ($webhook->getUserId()) {
|
||||
/** @var ?User $user */
|
||||
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $webhook->getUserId());
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($webhook->getUserId());
|
||||
|
||||
if (!$user) {
|
||||
foreach ($itemList as $item) {
|
||||
@@ -352,11 +347,11 @@ class Queue
|
||||
|
||||
protected function succeedQueueItem(WebhookQueueItem $item): void
|
||||
{
|
||||
$item->set([
|
||||
'attempts' => $item->getAttempts() + 1,
|
||||
'status' => WebhookQueueItem::STATUS_SUCCESS,
|
||||
'processedAt' => DateTimeUtil::getSystemNowString(),
|
||||
]);
|
||||
|
||||
$item
|
||||
->setAttempts($item->getAttempts() + 1)
|
||||
->setStatus(WebhookQueueItem::STATUS_SUCCESS)
|
||||
->setProcessedAt(DateTime::createNow());
|
||||
|
||||
$this->entityManager->saveEntity($item);
|
||||
}
|
||||
@@ -372,18 +367,14 @@ class Queue
|
||||
$maxAttemptsNumber = 0;
|
||||
}
|
||||
|
||||
$dt = new DateTime();
|
||||
$dt->modify($period);
|
||||
$processAt = DateTime::createNow()->modify($period);
|
||||
|
||||
$item->set([
|
||||
'attempts' => $attempts,
|
||||
'processAt' => $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
|
||||
]);
|
||||
$item->setAttempts($attempts);
|
||||
$item->setProcessAt($processAt);
|
||||
|
||||
if ($attempts >= $maxAttemptsNumber) {
|
||||
$item->set('status', WebhookQueueItem::STATUS_FAILED);
|
||||
/** @noinspection PhpRedundantOptionalArgumentInspection */
|
||||
$item->set('processAt', null);
|
||||
$item->setStatus(WebhookQueueItem::STATUS_FAILED);
|
||||
$item->setProcessAt(null);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($item);
|
||||
|
||||
@@ -55,6 +55,7 @@ class Email extends Entity
|
||||
|
||||
public const USERS_COLUMN_IS_READ = 'isRead';
|
||||
public const USERS_COLUMN_IN_TRASH = 'inTrash';
|
||||
public const USERS_COLUMN_IN_ARCHIVE = 'inArchive';
|
||||
public const USERS_COLUMN_FOLDER_ID = 'folderId';
|
||||
public const USERS_COLUMN_IS_IMPORTANT = 'isImportant';
|
||||
|
||||
|
||||
@@ -73,4 +73,11 @@ class EmailAddress extends Entity
|
||||
{
|
||||
return $this->get('invalid');
|
||||
}
|
||||
|
||||
public function setOptedOut(bool $optedOut): self
|
||||
{
|
||||
$this->set('optOut', $optedOut);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,15 @@
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
class EmailFolder extends \Espo\Core\ORM\Entity
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
class EmailFolder extends Entity
|
||||
{
|
||||
public const ENTITY_TYPE = 'EmailFolder';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->get('name') ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,113 @@
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use RuntimeException;
|
||||
|
||||
class ExternalAccount extends Integration
|
||||
{
|
||||
public const ENTITY_TYPE = 'ExternalAccount';
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) $this->get('enabled');
|
||||
}
|
||||
|
||||
public function setIsEnabled(bool $isEnabled): self
|
||||
{
|
||||
$this->set('enabled', $isEnabled);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function unsetData(): self
|
||||
{
|
||||
$this->set(['data' => null]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIsLocked(bool $isLocked): self
|
||||
{
|
||||
$this->set('isLocked', $isLocked);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return (bool) $this->get('isLocked');
|
||||
}
|
||||
|
||||
public function getRefreshTokenAttempts(): int
|
||||
{
|
||||
return (int) ($this->get('refreshTokenAttempts') ?? 0);
|
||||
}
|
||||
|
||||
public function getAccessToken(): ?string
|
||||
{
|
||||
return $this->get('accessToken');
|
||||
}
|
||||
|
||||
public function getRefreshToken(): ?string
|
||||
{
|
||||
return $this->get('refreshToken');
|
||||
}
|
||||
|
||||
public function getTokenType(): ?string
|
||||
{
|
||||
return $this->get('tokenType');
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?DateTime
|
||||
{
|
||||
$raw = $this->get('expiresAt');
|
||||
|
||||
if (!$raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime::fromString($raw);
|
||||
}
|
||||
catch (RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setAccessToken(?string $accessToken): self
|
||||
{
|
||||
$this->set('accessToken', $accessToken);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setTokenType(?string $tokenType): self
|
||||
{
|
||||
$this->set('tokenType', $tokenType);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRefreshToken(?string $refreshToken): self
|
||||
{
|
||||
$this->set('refreshToken', $refreshToken);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
public function setExpiresAt(?string $expiresAt): self
|
||||
{
|
||||
$this->set('expiresAt', $expiresAt);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRefreshTokenAttempts(?int $refreshTokenAttempts): self
|
||||
{
|
||||
$this->set('refreshTokenAttempts', $refreshTokenAttempts);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,4 +183,9 @@ class Integration extends Entity
|
||||
|
||||
return (object) $arr;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) $this->get('enabled');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\LinkParent;
|
||||
|
||||
use Espo\Core\ORM\Entity;
|
||||
use stdClass;
|
||||
|
||||
class Notification extends \Espo\Core\ORM\Entity
|
||||
class Notification extends Entity
|
||||
{
|
||||
public const ENTITY_TYPE = 'Notification';
|
||||
|
||||
@@ -109,4 +110,16 @@ class Notification extends \Espo\Core\ORM\Entity
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->get('read');
|
||||
}
|
||||
|
||||
public function setRead(bool $read = true): self
|
||||
{
|
||||
$this->set('read', $read);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,9 @@ class Preferences extends \Espo\Core\ORM\Entity
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getTimeZone(): ?string
|
||||
{
|
||||
return $this->get('timeZone');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,12 @@ class Team extends \Espo\Core\ORM\Entity
|
||||
/** @var ?Link */
|
||||
return $this->getValueObject('layoutSet');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getPositionList(): array
|
||||
{
|
||||
return $this->get('positionList') ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Field\LinkParent;
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
use stdClass;
|
||||
use LogicException;
|
||||
|
||||
class UniqueId extends \Espo\Core\ORM\Entity
|
||||
class UniqueId extends Entity
|
||||
{
|
||||
public const ENTITY_TYPE = 'UniqueId';
|
||||
|
||||
@@ -65,4 +67,38 @@ class UniqueId extends \Espo\Core\ORM\Entity
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|stdClass $data
|
||||
*/
|
||||
public function setData(array|stdClass $data): self
|
||||
{
|
||||
$this->set('data', $data);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setTarget(?LinkParent $target): self
|
||||
{
|
||||
if (!$target) {
|
||||
$this->setMultiple([
|
||||
'targetId' => null,
|
||||
'targetType' => null,
|
||||
]);
|
||||
} else {
|
||||
$this->setMultiple([
|
||||
'targetId' => $target->getId(),
|
||||
'targetType' => $target->getEntityType(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setTerminateAt(?DateTime $terminateAt): self
|
||||
{
|
||||
$this->setValueObject('terminateAt', $terminateAt);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,16 @@
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
class WebhookEventQueueItem extends \Espo\Core\ORM\Entity
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
class WebhookEventQueueItem extends Entity
|
||||
{
|
||||
public const ENTITY_TYPE = 'WebhookEventQueueItem';
|
||||
|
||||
public function setIsProcessed(bool $isProcessed = true): self
|
||||
{
|
||||
$this->set('isProcessed', $isProcessed);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
class WebhookQueueItem extends Entity
|
||||
@@ -43,4 +44,44 @@ class WebhookQueueItem extends Entity
|
||||
{
|
||||
return $this->get('attempts') ?? 0;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): self
|
||||
{
|
||||
$this->set('status', $status);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAttempts(?int $attempts): self
|
||||
{
|
||||
$this->set('attempts', $attempts);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setProcessAt(?DateTime $processAt): self
|
||||
{
|
||||
if (!$processAt) {
|
||||
$this->set('processAt', $processAt);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->set('processAt', $processAt->toString());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setProcessedAt(?DateTime $processedAt): self
|
||||
{
|
||||
if (!$processedAt) {
|
||||
$this->set('processedAt', $processedAt);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->set('processedAt', $processedAt->toString());
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
84
application/Espo/Hooks/Common/DeleteId.php
Normal file
84
application/Espo/Hooks/Common/DeleteId.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?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\Hooks\Common;
|
||||
|
||||
use Espo\Core\Hook\Hook\BeforeRemove;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\RemoveOptions;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
use Espo\Core\Hook\Hook\BeforeSave;
|
||||
|
||||
/**
|
||||
* Handles 'deleteId' on soft-deletes.
|
||||
*
|
||||
* @implements BeforeSave<Entity>
|
||||
* @implements BeforeRemove<Entity>
|
||||
*/
|
||||
class DeleteId implements BeforeSave, BeforeRemove
|
||||
{
|
||||
private const ID_ATTR = 'deleteId';
|
||||
private const DELETED_ATTR = 'deleted';
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function beforeRemove(Entity $entity, RemoveOptions $options): void
|
||||
{
|
||||
if (!$this->hasDeleteId($entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set(self::ID_ATTR, Util::generateId());
|
||||
}
|
||||
|
||||
public function beforeSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if (!$this->hasDeleteId($entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->isAttributeChanged(self::DELETED_ATTR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleteId = $entity->get(self::DELETED_ATTR) ? Util::generateId() : '0';
|
||||
|
||||
$entity->set(self::ID_ATTR, $deleteId);
|
||||
}
|
||||
|
||||
private function hasDeleteId(Entity $entity): bool
|
||||
{
|
||||
return $entity->hasAttribute(self::DELETED_ATTR) &&
|
||||
$this->metadata->get("entityDefs.{$entity->getEntityType()}.deleteId");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user