Compare commits

...

125 Commits

Author SHA1 Message Date
chaim 04178c5af4 Fix: force HTTPS=on in Apache for Traefik proxy 2026-03-15 14:35:00 +00:00
chaim 718b9ba591 Fix: configure Apache to trust X-Forwarded-Proto from Traefik 2026-03-15 14:33:07 +00:00
chaim 12f7304b12 Simplify: remove custom entrypoint, use Coolify post-deploy instead 2026-03-15 14:22:47 +00:00
chaim a967d1b6ab Add auto cache clear and rebuild on deploy 2026-03-15 14:19:44 +00:00
chaim f60bf6019e Simplify: use official EspoCRM image for extension development 2026-03-15 14:18:07 +00:00
chaim 9a1582dd7c Fix: use grunt offline to skip composer in frontend stage 2026-03-15 14:17:15 +00:00
chaim d1ab599390 Fix: copy dev/ dir for composer post-install script 2026-03-15 14:14:02 +00:00
chaim f6c845e627 Add minimal secure Dockerfile for Coolify deployment 2026-03-15 14:11:32 +00:00
Yurii 501de26774 add test namespaces 2026-03-15 13:36:33 +02:00
Yurii 7be93fb699 test namespace specific 2026-03-15 13:09:18 +02:00
Yurii Kuznietsov d3d3e1bb82 http client (#3610) 2026-03-15 12:55:23 +02:00
Yurii 72ea83fbca style fix 2026-03-14 18:17:31 +02:00
Yurii 83362fe3c1 last viewed ui impr 2026-03-14 18:08:33 +02:00
Yurii 1aabf6eea6 translate options use null coalesce 2026-03-14 09:42:10 +02:00
Yurii f23634ee61 wysiwyg default 2026-03-12 18:59:47 +02:00
Yurii 089bb01e12 online meeting framework 2026-03-12 18:44:25 +02:00
Yurii 7ba772f2cc css improvements 2026-03-12 10:02:59 +02:00
Yurii 3ab26a5235 last viewed: display created 2026-03-11 22:25:47 +02:00
Yurii ca27df06d9 last viewed ux improvement 2026-03-11 22:17:27 +02:00
Yurii a7f2faa53b remove font-awesome old css 2026-03-11 14:11:34 +02:00
Yurii 6dbca2f584 cleanup 2026-03-11 10:07:43 +02:00
Yurii fe505aea1e stream test 2026-03-11 10:04:16 +02:00
Yurii 159284f5ee update tar 2026-03-11 09:15:01 +02:00
Yurii 9ca71c91bf ignore password 2026-03-10 20:32:33 +02:00
Yurii d3f03949a1 duplicate ignore 2026-03-10 20:31:19 +02:00
Yurii 7c25f2a8fe link one duplicate ignore 2026-03-10 20:22:41 +02:00
Yurii 8a03ad6240 portal roles field create disabled 2026-03-10 20:14:26 +02:00
Yurii 212d383ac1 cleanup 2026-03-10 19:41:27 +02:00
Yurii bf26581339 Merge branch 'f/notificaiton-grouping' 2026-03-10 18:45:02 +02:00
Yurii 49f7ac30ca notification grouping 2026-03-10 18:44:31 +02:00
Yurii 7d195793a3 Merge branch 'fix' 2026-03-10 15:22:33 +02:00
Yurii e41a1bc410 Merge branch 'master' of github.com:espocrm/espocrm 2026-03-10 11:41:48 +02:00
Yurii b4e38002fe Merge branch 'fix' 2026-03-10 11:39:15 +02:00
SuchAFuriousDeath 49f48340ac Fix attachment MIME type detection for non-image file types (#3604)
The mail parser's MIME type fallback map only contained image types (jpg,
jpeg, png, gif, webp), causing attachments sent as application/octet-stream
(common with Lotus Notes and other legacy clients) to be stored with a NULL
type. This made PDFs and documents download instead of opening inline.

Extended $extMimeTypeMap with common document, archive and media types.

Also fixed getAttachmentFilenameExtension() which used explode('.')[1] to
get the extension — this returns the part after the *first* dot rather than
the last, breaking for filenames like "report.2024.pdf". Replaced with
pathinfo() which correctly extracts the final extension.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:56:44 +02:00
Yurii 1877aeecb0 Merge branch 'fix' 2026-03-06 09:30:37 +02:00
Yurii 669b237893 change indexes 2026-03-05 19:46:40 +02:00
Yurii c22390ffc7 entity manager params group index 2026-03-05 19:42:16 +02:00
Yurii 10dd636646 update dep 2026-03-05 17:00:31 +02:00
Yurii 82c7c697e6 object check 2026-03-05 14:11:15 +02:00
Yurii 8c6eb518d8 comment 2026-03-05 13:58:10 +02:00
Yurii e94a7953be fix cascade removal conversion 2026-03-05 13:56:54 +02:00
Yurii 8561b1af65 cascade removal 2026-03-05 13:22:58 +02:00
Yurii 9e2029dc0a cascading fields cleanup 2026-03-05 09:49:02 +02:00
Yurii fc616f4fea Merge branch 'fix' 2026-03-05 09:48:24 +02:00
Yurii Kuznietsov 1f47c5b80f Update CONTRIBUTING.md 2026-03-05 08:37:45 +02:00
Yurii 6cfa26ee10 update dompurify 2026-03-05 08:19:51 +02:00
Yurii ecd95d16f2 update dep 2026-03-05 08:17:08 +02:00
Yurii 0c1e4bf9d8 flat equal check 2026-03-04 21:59:03 +02:00
Yurii 61677e34e0 equal check improvement and tests 2026-03-04 15:56:44 +02:00
Yurii ca136225cb ref 2026-03-04 15:37:04 +02:00
Yurii 5462854f53 comment, internal method 2026-03-04 15:25:23 +02:00
Yurii 6902ae0e04 move SaveContext to ORM namespace 2026-03-04 15:21:04 +02:00
Yurii 4a60d17936 webhook use url type 2026-03-04 14:06:11 +02:00
Yurii 0fbfe21258 url: protocol required 2026-03-04 14:05:48 +02:00
Yurii 6fa1ff811b remove paragraph 2026-03-04 13:33:21 +02:00
Yurii a0e709dd86 Merge branch 'fix' 2026-03-04 13:15:43 +02:00
Yurii 50c63fa986 deprecations 2026-03-03 22:27:30 +02:00
Yurii 409cb3fa19 restore package.json 2026-03-03 22:20:36 +02:00
dependabot[bot] 5766355661 Bump underscore from 1.13.1 to 1.13.8 (#3598)
Bumps [underscore](https://github.com/jashkenas/underscore) from 1.13.1 to 1.13.8.
- [Commits](https://github.com/jashkenas/underscore/compare/1.13.1...1.13.8)

---
updated-dependencies:
- dependency-name: underscore
  dependency-version: 1.13.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 22:18:47 +02:00
Yurii 04280e68c6 getContext in save options 2026-03-03 21:42:06 +02:00
Yurii 3375ca3181 refactoring, save context in late hooks 2026-03-03 20:47:03 +02:00
Yurii 04794bb2aa method usage fix 2026-03-03 18:09:35 +02:00
Yurii f6a6aefd5e late save/remove hooks 2026-03-03 16:49:01 +02:00
Yurii 7296fcfc77 fix typo 2026-03-03 16:31:38 +02:00
Yurii 19d3789c24 transactional save 2026-03-03 16:01:15 +02:00
Yurii f298d6ff5a Merge branch 'fix' 2026-03-03 15:30:27 +02:00
Yurii 6fbd21822e reminders: autocomplete improve 2026-03-03 15:08:06 +02:00
Yurii 1c270a9d2e dep updates 2026-03-03 14:46:31 +02:00
Yurii c52846f480 contact: relate with account if related through accounts 2026-03-03 13:34:55 +02:00
Yurii d2f51066c0 contact restore account on save 2026-03-03 11:22:19 +02:00
Yurii 7537fd25fd link vo new methods 2026-03-03 11:16:35 +02:00
Yurii 10ccbe4709 contact: set account id 2026-03-03 10:47:36 +02:00
Yurii 3a161ef0e7 update minimatch transitive dep 2026-03-02 22:38:09 +02:00
Yurii a778611edf update glob 2026-03-02 22:25:43 +02:00
Yurii cb291c2e5e update minimatch dep 2026-03-02 22:14:44 +02:00
Yurii 2cb70969bf do not allow forbidden link name 2026-03-02 20:32:00 +02:00
Yurii 3a2eb429f1 fix jsdoc 2026-03-02 20:24:16 +02:00
Yurii f7c2611200 Merge branch 'fix' 2026-03-02 19:09:10 +02:00
Yurii beef740114 move dynamic logic defs 2026-03-02 18:57:42 +02:00
Yurii 35d96b36ab error close button 2026-03-02 18:31:51 +02:00
Yurii 74a3e9f6bd cascading links 2026-03-02 18:09:41 +02:00
Yurii 38ab9de081 po file remove line 2026-03-02 09:31:45 +02:00
Yurii 669e78e072 fix email address escaped 2026-02-27 15:03:40 +02:00
Yurii 9166277a01 fix test 2026-02-27 14:24:00 +02:00
Yurii dcd3ce66ce lockable 2026-02-27 12:15:13 +02:00
Yurii feac638722 collection default template 2026-02-26 16:58:13 +02:00
Yurii 29a5d39768 merge in transaction 2026-02-26 16:57:41 +02:00
Yurii 0368d0569c client record actions duplicate prevention 2026-02-26 10:37:04 +02:00
Yurii 1ba438fa9f add throw 2026-02-26 09:55:49 +02:00
Yurii 198b9c78fc categories update hook rebuild scope 2026-02-26 09:53:02 +02:00
Yurii 145e521bd5 fix nl 2026-02-26 09:52:21 +02:00
dependabot[bot] 69b626b4da Bump rollup from 4.44.0 to 4.59.0 (#3591)
Bumps [rollup](https://github.com/rollup/rollup) from 4.44.0 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.0...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 09:22:10 +02:00
dependabot[bot] 482e6cc308 Bump basic-ftp from 5.0.5 to 5.2.0 (#3590)
Bumps [basic-ftp](https://github.com/patrickjuchli/basic-ftp) from 5.0.5 to 5.2.0.
- [Release notes](https://github.com/patrickjuchli/basic-ftp/releases)
- [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.0.5...v5.2.0)

---
updated-dependencies:
- dependency-name: basic-ftp
  dependency-version: 5.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 08:57:00 +02:00
Yurii d678307d2d oidc pkce support 2026-02-25 15:07:54 +02:00
Yurii 74b1c2ba98 Merge branch 'fix' 2026-02-25 12:56:55 +02:00
Yurii 1c550da572 oidc login fail console log 2026-02-25 12:25:08 +02:00
Yurii b48b033be8 ux fix 2026-02-25 11:36:06 +02:00
Yurii c392fabc14 categories for custom entity types 2026-02-24 17:10:45 +02:00
Yurii 1de9046b3d grunt minimatch override 2026-02-24 12:48:42 +02:00
Yurii b007bef366 Revert "override dependency"
This reverts commit 33c8e202bc.
2026-02-24 12:36:45 +02:00
Yurii 33c8e202bc override dependency 2026-02-23 18:12:03 +02:00
Yurii bbf77b16bd update npm dependency 2026-02-23 17:24:20 +02:00
Yurii 4da48a15a9 Merge branch 'fix' 2026-02-23 17:20:04 +02:00
Yurii ee34bf8cba allow no colloaboarators if no assignment permission 2026-02-22 12:57:38 +02:00
Yurii 563843cac8 collaborators, add task to defaults 2026-02-21 23:39:43 +02:00
Yurii 9463c9ea55 forbid disabled link 2026-02-21 17:28:47 +02:00
Yurii 39b461a618 enable collaborators for tasks and cases 2026-02-21 16:50:52 +02:00
Yurii 65d2c1de07 added const 2026-02-21 16:17:12 +02:00
Yurii adccaa7951 metadata legacy fix 2026-02-21 16:06:03 +02:00
Yurii f9d0f3bcaf navbar url open in new tab parameter 2026-02-21 12:53:07 +02:00
Yurii 3321bf1020 category save in transaction 2026-02-21 12:38:35 +02:00
Yurii d3da6fc8cc update npm packages 2026-02-21 10:24:53 +02:00
Yurii 6c0e3c67a8 line breaks 2026-02-21 10:08:19 +02:00
dependabot[bot] 2cd9b532f0 Bump directorytree/imapengine from 1.19.2 to 1.22.3 (#3580)
Bumps [directorytree/imapengine](https://github.com/DirectoryTree/ImapEngine) from 1.19.2 to 1.22.3.
- [Release notes](https://github.com/DirectoryTree/ImapEngine/releases)
- [Commits](https://github.com/DirectoryTree/ImapEngine/compare/v1.19.2...v1.22.3)

---
updated-dependencies:
- dependency-name: directorytree/imapengine
  dependency-version: 1.22.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 17:22:43 +02:00
dependabot[bot] ce1e664d63 Bump tar from 7.5.7 to 7.5.8 (#3582)
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.8.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.8)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.8
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 20:52:34 +02:00
Yurii 2f97cf36ea Merge branch 'fix' 2026-02-19 10:17:13 +02:00
Yurii d7764af73d fix error message 2026-02-16 17:39:27 +02:00
Yurii dc738fdbfe Merge branch 'fix' 2026-02-16 09:44:06 +02:00
Yurii 44c20522a0 dompdf fonts 2026-02-14 19:05:37 +02:00
Yurii 3161d0c436 Merge branch 'fix' 2026-02-14 16:25:52 +02:00
dependabot[bot] 6930658c27 Bump qs from 6.14.1 to 6.14.2 (#3577)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.14.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 14:57:04 +02:00
Yurii 61e427d621 list settings no session storage 2026-02-06 10:03:38 +02:00
Yurii ecad172891 clear settings temp storage 2026-02-05 15:40:28 +02:00
Yurii a694db623c fix 2026-02-05 15:23:46 +02:00
Yurii ed7677a4d7 list setting without storing 2026-02-05 15:22:01 +02:00
288 changed files with 11914 additions and 1842 deletions
+3 -2
View File
@@ -3,8 +3,9 @@
#### Guidelines
When reporting a possible bug, provide detail steps so that we will be able
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.
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.
#### No support
-4
View File
@@ -4,10 +4,6 @@
If you believe you have discovered a vulnerability in EspoCRM, please contacts us via [this](https://www.espocrm.com/contacts/) or [this](https://www.espocrm.com/support/) forms. Or create a private vulnerability report on GitHub.
### LLM slop
Low-effort, LLM-generated reports are not allowed. In such cases, if it is evident that the author does not understand the subject they submitted, the account will be blocked.
## Supported versions
For severe vulnerabilities we provide fixes for 2 minor versions (the second number in the version string) back from the current stable version.
+18
View File
@@ -0,0 +1,18 @@
###############################################################################
# EspoCRM - Development Server for Extensions
# Based on official image, overlays custom code on each deploy
###############################################################################
FROM espocrm/espocrm:latest
# Fix redirect loop: tell Apache it's behind HTTPS proxy (Traefik)
RUN echo 'SetEnv HTTPS on' > /etc/apache2/conf-available/force-https.conf && \
a2enconf force-https
# Copy custom extensions and modifications
COPY custom/ /var/www/html/custom/
# Ensure correct permissions
RUN chown -R www-data:www-data /var/www/html/custom
EXPOSE 80
+5
View File
@@ -242,6 +242,11 @@ class Binding implements BindingProcessor
'Espo\\Core\\Utils\\Config\\ApplicationConfig',
'applicationConfig'
);
$binder->bindService(
'Espo\\Core\\Session\\Session',
'session'
);
}
private function bindCore(Binder $binder): void
@@ -280,7 +280,7 @@ class Email implements AssignmentNotificator
->setType(Notification::TYPE_EMAIL_RECEIVED)
->setUserId($userId)
->setData($data)
->setRelated(LinkParent::createFromEntity($entity))
->setRelated(LinkParent::fromEntity($entity))
->setActionId($params->getActionId());
$this->entityManager->saveEntity($notification);
@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\FieldValidators\Common\Link;
use Espo\Core\Field\Link;
use Espo\Core\FieldValidation\Validator;
use Espo\Core\FieldValidation\Validator\Data;
use Espo\Core\FieldValidation\Validator\Failure;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\DynamicLogic\CascadingFields\ItemsProvider;
use Espo\Tools\DynamicLogic\CascadingFields\ValidationHelper;
/**
* @implements Validator<Entity>
*/
class CascadingSelect implements Validator
{
public function __construct(
private EntityManager $entityManager,
private Defs $defs,
private ValidationHelper $helper,
private ItemsProvider $itemsProvider,
) {}
public function validate(Entity $entity, string $field, Data $data): ?Failure
{
if (!$entity instanceof CoreEntity) {
return null;
}
$items = $this->itemsProvider->get($entity->getEntityType(), $field);
if (!$items) {
return null;
}
$linkValue = $entity->getValueObject($field);
if (!$linkValue instanceof Link) {
return null;
}
$entityType = $this->defs
->getEntity($entity->getEntityType())
->tryGetRelation($field)
?->tryGetForeignEntityType();
if (!$entityType) {
return null;
}
$valueEntity = $this->entityManager->getEntityById($entityType, $linkValue->getId());
if (!$valueEntity instanceof CoreEntity) {
return null;
}
foreach ($items as $item) {
$itemFailure = $this->helper->validateItem($entity, $valueEntity, $item);
if ($itemFailure) {
return $itemFailure;
}
}
return null;
}
}
@@ -0,0 +1,110 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\FieldValidators\Common\LinkMultiple;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\FieldValidation\Validator;
use Espo\Core\FieldValidation\Validator\Data;
use Espo\Core\FieldValidation\Validator\Failure;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Tools\DynamicLogic\CascadingFields\ItemsProvider;
use Espo\Tools\DynamicLogic\CascadingFields\ValidationHelper;
/**
* @implements Validator<Entity>
*/
class CascadingSelect implements Validator
{
public function __construct(
private EntityManager $entityManager,
private Defs $defs,
private ValidationHelper $helper,
private ItemsProvider $itemsProvider,
) {}
public function validate(Entity $entity, string $field, Data $data): ?Failure
{
if (!$entity instanceof CoreEntity) {
return null;
}
$items = $this->itemsProvider->get($entity->getEntityType(), $field);
if (!$items) {
return null;
}
$linkValue = $entity->getValueObject($field);
if (!$linkValue instanceof LinkMultiple || !$linkValue->getIdList()) {
return null;
}
$entityType = $this->defs
->getEntity($entity->getEntityType())
->tryGetRelation($field)
?->tryGetForeignEntityType();
if (!$entityType) {
return null;
}
$valueEntities = $this->entityManager
->getRDBRepository($entityType)
->where([
Attribute::ID => $linkValue->getIdList(),
])
->find();
if (!count($valueEntities)) {
return null;
}
foreach ($valueEntities as $valueEntity) {
if (!$valueEntity instanceof CoreEntity) {
continue;
}
foreach ($items as $item) {
$itemFailure = $this->helper->validateItem($entity, $valueEntity, $item);
if ($itemFailure) {
return $itemFailure;
}
}
}
return null;
}
}
@@ -30,19 +30,17 @@
namespace Espo\Classes\FieldValidators;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
class UrlType
{
private Metadata $metadata;
private VarcharType $varcharType;
public function __construct(Metadata $metadata, VarcharType $varcharType)
{
$this->metadata = $metadata;
$this->varcharType = $varcharType;
}
public function __construct(
private Metadata $metadata,
private VarcharType $varcharType,
private Defs $defs,
) {}
public function checkRequired(Entity $entity, string $field): bool
{
@@ -62,6 +60,15 @@ class UrlType
return true;
}
if (
$this->defs
->getEntity($entity->getEntityType())
->tryGetField($field)
?->getParam('protocolRequired')
) {
return filter_var($value, FILTER_VALIDATE_URL) !== false;
}
/** @var string $pattern */
$pattern = $this->metadata->get(['app', 'regExpPatterns', 'uriOptionalProtocol', 'pattern']);
@@ -0,0 +1,50 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Record\Common\Lock;
use Espo\Core\Record\DeleteParams;
use Espo\Core\Record\Hook\DeleteHook;
use Espo\ORM\Entity;
use Espo\Tools\Lock\LockValidationHelper;
/**
* @noinspection PhpUnused
*/
class BeforeDeleteValidation implements DeleteHook
{
public function __construct(
private LockValidationHelper $lockValidationHelper,
) {}
public function process(Entity $entity, DeleteParams $params): void
{
$this->lockValidationHelper->validateBeforeRemove($entity);
}
}
@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Record\Common\Lock;
use Espo\Core\Record\Hook\SaveHook;
use Espo\ORM\Entity;
use Espo\Tools\Lock\LockValidationHelper;
/**
* @noinspection PhpUnused
*/
class BeforeSaveValidation implements SaveHook
{
public function __construct(
private LockValidationHelper $lockValidationHelper,
) {}
/**
* @inheritDoc
*/
public function process(Entity $entity): void
{
$this->lockValidationHelper->validateBeforeSave($entity);
}
}
@@ -33,6 +33,7 @@ use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\Tools\EntityManager\EntityManager as EntityManagerTool;
use Espo\Core\Api\Request;
use Espo\Core\Exceptions\BadRequest;
@@ -443,11 +444,15 @@ class EntityManager
throw new BadRequest();
}
/** @var array{readOnly?: bool} $params */
/** @var array{readOnly?: bool, cascadeRemocal?: bool} $params */
$params = [];
if (property_exists($rawParams, 'readOnly')) {
$params['readOnly'] = (bool) $rawParams->readOnly;
if (property_exists($rawParams, RelationParam::READ_ONLY)) {
$params[RelationParam::READ_ONLY] = (bool) $rawParams->{RelationParam::READ_ONLY};
}
if (property_exists($rawParams, RelationParam::CASCADE_REMOVAL)) {
$params[RelationParam::CASCADE_REMOVAL] = (bool) $rawParams->{RelationParam::CASCADE_REMOVAL};
}
$this->linkManager->updateParams($entityType, $link, $params);
@@ -30,6 +30,7 @@
namespace Espo\Controllers;
use Espo\Core\Name\Field;
use Espo\Entities\Notification as NotificationEntity;
use Espo\Tools\Notification\RecordService as Service;
use Espo\Core\Api\Request;
@@ -60,6 +61,7 @@ class Notification extends RecordBase
$maxSize = $searchParamsAux->getMaxSize();
$after = $request->getQueryParam('after');
$beforeNumber = $request->getQueryParam('beforeNumber');
$searchParams = SearchParams
::create()
@@ -76,19 +78,20 @@ class Notification extends RecordBase
->setValue($after)
->build()
);
}
$recordCollection = $this->getNotificationService()->get($this->user, $searchParams);
$recordCollection = $this->getNotificationService()->get($this->user, $searchParams, $beforeNumber);
return $recordCollection->toApiOutput();
}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function getActionNotReadCount(): int
{
$userId = $this->user->getId();
return $this->getNotificationService()->getNotReadCount($userId);
return $this->getNotificationService()->getNotReadCount($this->user);
}
public function postActionMarkAllRead(Request $request): bool
+15
View File
@@ -276,6 +276,21 @@ class Acl
return $this->aclManager->checkField($this->user, $scope, $field, $action);
}
/**
* Check access to a link.
*
* @param string $scope A scope (entity type).
* @param string $link A link to check.
* @param Table::ACTION_READ|Table::ACTION_EDIT $action An action.
* @noinspection PhpDocSignatureInspection
*
* @since 9.4.0
*/
public function checkLink(string $scope, string $link, string $action = Table::ACTION_READ): bool
{
return $this->aclManager->checkLink($this->user, $scope, $link, $action);
}
/**
* Get links forbidden for a user.
*
@@ -38,6 +38,7 @@ use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
@@ -209,7 +210,11 @@ class Helper
$userIds = $entity->getLinkMultipleIdList($field);
if ($userIds === []) {
if ($assignmentPermission === Table::LEVEL_NO && !$user->isApi()) {
if (
$assignmentPermission === Table::LEVEL_NO &&
!$user->isApi() &&
$field !== Field::COLLABORATORS
) {
return false;
}
@@ -329,6 +334,7 @@ class Helper
}
return $fieldDefs->getType() === FieldType::LINK &&
!$fieldDefs->getParam(FieldParam::DISABLED) &&
$entityDefs->hasRelation(Field::ASSIGNED_USER) &&
$entityDefs->getRelation(Field::ASSIGNED_USER)->getForeignEntityType() === User::ENTITY_TYPE;
}
@@ -187,6 +187,13 @@ class GlobalRestriction
$value = $this->metadata->get(['entityDefs', $scope, 'links', $link, $type]);
}
if (
$type === self::TYPE_FORBIDDEN &&
$this->metadata->get("entityDefs.$scope.links.$link.disabled")
) {
$value = true;
}
if (!$value) {
continue;
}
+1
View File
@@ -43,4 +43,5 @@ class Permission
public const USER_CALENDAR = 'userCalendar';
public const FOLLOWER_MANAGEMENT = 'followerManagement';
public const GROUP_EMAIL_ACCOUNT = 'groupEmailAccount';
public const LOCK = 'lock';
}
+15
View File
@@ -579,6 +579,21 @@ class AclManager
return !in_array($field, $this->getScopeForbiddenFieldList($user, $scope, $action));
}
/**
* Check access to a link.
*
* @param string $scope A scope (entity type).
* @param string $link A link to check.
* @param Table::ACTION_READ|Table::ACTION_EDIT $action An action.
* @noinspection PhpDocSignatureInspection
*
* @since 9.4.0
*/
public function checkLink(User $user, string $scope, string $link, string $action = Table::ACTION_READ): bool
{
return !in_array($link, $this->getScopeForbiddenLinkList($user, $scope, $action));
}
/**
* Whether a user has access to another user over a specific permission.
*
+2
View File
@@ -30,6 +30,7 @@
namespace Espo\Core\Action;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
@@ -39,6 +40,7 @@ interface Action
* @throws Forbidden
* @throws BadRequest
* @throws NotFound
* @throws Conflict
*/
public function process(Params $params, Data $data): void;
}
@@ -0,0 +1,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Action\Actions;
use Espo\Core\Action\Action;
use Espo\Core\Action\Data;
use Espo\Core\Action\Params;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Record\EntityProvider;
use Espo\Tools\Lock\LockService;
/**
* @noinspection PhpUnused
*/
class Lock implements Action
{
public function __construct(
private LockService $lockService,
private EntityProvider $entityProvider,
) {}
public function process(Params $params, Data $data): void
{
$entityType = $params->getEntityType();
$id = $params->getId();
if (!$this->lockService->isEnabled($entityType)) {
throw new ForbiddenSilent("Not enabled.");
}
if (!$this->lockService->isAllowed($entityType)) {
throw new ForbiddenSilent("Not allowed.");
}
$entity = $this->entityProvider->get($entityType, $id);
try {
$this->lockService->lock($entity);
} catch (Error $e) {
throw new Forbidden(previous: $e);
}
}
}
@@ -32,14 +32,17 @@ namespace Espo\Core\Action\Actions\Merge;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Action\Params;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Core\Name\Link;
use Espo\Core\ORM\EntityManager;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\ActionHistory\Action;
use Espo\Core\Record\DeleteParams;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Record\UpdateParams;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\ObjectUtil;
use Espo\Entities\Attachment;
@@ -47,7 +50,6 @@ use Espo\Entities\Note;
use Espo\ORM\Entity;
use Espo\Entities\EmailAddress;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Type\RelationType;
use stdClass;
@@ -64,8 +66,25 @@ class Merger
* @param string[] $sourceIdList
* @throws NotFound
* @throws Forbidden
* @throws Conflict
* @throws BadRequest
* @noinspection PhpDocRedundantThrowsInspection
*/
public function process(Params $params, array $sourceIdList, stdClass $data): void
{
$this->entityManager->getTransactionManager()->run(function () use ($params, $sourceIdList, $data) {
$this->processInternal($params, $sourceIdList, $data);
});
}
/**
* @param string[] $sourceIdList
* @throws NotFound
* @throws Forbidden
* @throws Conflict
* @throws BadRequest
*/
private function processInternal(Params $params, array $sourceIdList, stdClass $data): void
{
$clonedData = ObjectUtil::clone($data);
@@ -132,18 +151,10 @@ class Merger
$this->updateNotes($sourceEntity, $entity);
}
$mergeLinkList = $this->getMergeLinkList($entityType);
$this->mergeLinks($entity, $sourceEntityList);
foreach ($sourceEntityList as $sourceEntity) {
foreach ($mergeLinkList as $link) {
$this->updateRelations($sourceEntity, $entity, $link);
}
}
foreach ($sourceEntityList as $sourceEntity) {
$this->entityManager->removeEntity($sourceEntity);
$service->processActionHistoryRecord(Action::DELETE, $sourceEntity);
$service->delete($sourceEntity->getId(), DeleteParams::create());
}
if ($hasPhoneNumber) {
@@ -154,11 +165,7 @@ class Merger
$this->prepareEmailAddressData($emailAddressToRelateList, $clonedData);
}
$entity->set($clonedData);
$this->entityManager->saveEntity($entity);
$service->processActionHistoryRecord(Action::UPDATE, $entity);
$service->update($id, $clonedData, UpdateParams::create()->withSkipDuplicateCheck());
}
/**
@@ -436,4 +443,18 @@ class Merger
return $columnAttributeMap;
}
/**
* @param Entity[] $sourceEntityList
*/
private function mergeLinks(Entity $entity, array $sourceEntityList): void
{
$mergeLinkList = $this->getMergeLinkList($entity->getEntityType());
foreach ($sourceEntityList as $sourceEntity) {
foreach ($mergeLinkList as $link) {
$this->updateRelations($sourceEntity, $entity, $link);
}
}
}
}
@@ -0,0 +1,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Action\Actions;
use Espo\Core\Action\Action;
use Espo\Core\Action\Data;
use Espo\Core\Action\Params;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Record\EntityProvider;
use Espo\Tools\Lock\LockService;
/**
* @noinspection PhpUnused
*/
class Unlock implements Action
{
public function __construct(
private LockService $lockService,
private EntityProvider $entityProvider,
) {}
public function process(Params $params, Data $data): void
{
$entityType = $params->getEntityType();
$id = $params->getId();
if (!$this->lockService->isEnabled($entityType)) {
throw new ForbiddenSilent("Not enabled.");
}
if (!$this->lockService->isAllowed($entityType)) {
throw new ForbiddenSilent("Not allowed.");
}
$entity = $this->entityProvider->get($entityType, $id);
try {
$this->lockService->unlock($entity);
} catch (Error $e) {
throw new Forbidden(previous: $e);
}
}
}
+2
View File
@@ -31,6 +31,7 @@ namespace Espo\Core\Action;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
@@ -55,6 +56,7 @@ class Service
* @throws Forbidden
* @throws BadRequest
* @throws NotFound
* @throws Conflict
*/
public function process(string $entityType, string $action, string $id, stdClass $data): Entity
{
@@ -39,6 +39,7 @@ use Espo\Core\Exceptions\HasLogMessage;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Log;
use Espo\ORM\Exceptions\ValidationException;
use LogicException;
use Psr\Log\LogLevel;
use RuntimeException;
@@ -82,6 +83,11 @@ class ErrorOutput
NotFound::class,
];
/** @var array<class-string<Throwable>, int> */
private array $statusCodeMap = [
ValidationException::class => 409,
];
public function __construct(private Log $log)
{}
@@ -136,6 +142,8 @@ class ErrorOutput
'request' => $request,
]);
$statusCode = $this->statusCodeMap[$exception::class] ?? $statusCode;
if (!in_array($statusCode, $this->allowedStatusCodeList)) {
$statusCode = 500;
}
@@ -222,6 +222,11 @@ class ConfigDataProvider
return $this->object->get('oidcAuthorizationPrompt') ?? 'consent';
}
public function useAuthorizationPkce(): bool
{
return (bool) $this->object->get('oidcAuthorizationPkce');
}
public function getAuthorizationMaxAge(): ?int
{
return $this->config->get('oidcAuthorizationMaxAge');
@@ -41,6 +41,7 @@ use Espo\Core\Authentication\Jwt\Validator;
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
use Espo\Core\Authentication\Result;
use Espo\Core\Authentication\Result\FailReason;
use Espo\Core\Session\Session;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log;
use JsonException;
@@ -57,6 +58,8 @@ class Login implements LoginInterface
private const REQUEST_TIMEOUT = 10;
private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce';
public const string SESSION_KEY_CODE_VERIFIER = 'oidcCodeVerifier';
public function __construct(
private Espo $espoLogin,
private Log $log,
@@ -66,6 +69,7 @@ class Login implements LoginInterface
private UserProvider $userProvider,
private ApplicationState $applicationState,
private UserInfoDataProvider $userInfoDataProvider,
private Session $session,
) {}
public function login(Data $data, Request $request): Result
@@ -214,6 +218,7 @@ class Login implements LoginInterface
string $redirectUri,
string $clientSecret
): array {
$params = [
'grant_type' => 'authorization_code',
'client_id' => $clientId,
@@ -222,6 +227,12 @@ class Login implements LoginInterface
'redirect_uri' => $redirectUri,
];
if ($this->configDataProvider->useAuthorizationPkce()) {
$codeVerifier = $this->session->get(self::SESSION_KEY_CODE_VERIFIER);
$params['code_verifier'] = $codeVerifier;
}
$curl = curl_init();
curl_setopt_array($curl, [
@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Oidc;
class PkceUtil
{
private const string CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
private const int CODE_LENGTH = 64;
public static function generateCodeVerifier(): string
{
$output = '';
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
$output .= self::CHARACTERS[random_int(0, strlen(self::CHARACTERS) - 1)];
}
return $output;
}
public static function hashAndEncodeCodeVerifier(string $codeVerifier): string
{
$code = hash('sha256', $codeVerifier, true);
return rtrim(strtr(base64_encode($code), '+/', '-_'), '=');
}
}
+19
View File
@@ -29,6 +29,8 @@
namespace Espo\Core\Field;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use InvalidArgumentException;
/**
@@ -39,6 +41,9 @@ class Link
private string $id;
private ?string $name = null;
/**
* @throws InvalidArgumentException
*/
public function __construct(string $id)
{
if (!$id) {
@@ -78,9 +83,23 @@ class Link
/**
* Create from an ID.
*
* @throws InvalidArgumentException
*/
public static function create(string $id, ?string $name = null): self
{
return (new self($id))->withName($name);
}
/**
* Create from an entity.
*
* @throws InvalidArgumentException
*
* @since 9.4.0
*/
public static function fromEntity(Entity $entity): self
{
return self::create($entity->getId());
}
}
@@ -29,6 +29,8 @@
namespace Espo\Core\Field;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use InvalidArgumentException;
/**
@@ -99,8 +101,10 @@ class LinkMultipleItem
/**
* Clone with a name.
*
* @param ?string $name Is nullable since 9.4.0.
*/
public function withName(string $name): self
public function withName(?string $name): self
{
$obj = $this->clone();
$obj->name = $name;
@@ -134,6 +138,30 @@ class LinkMultipleItem
return $obj;
}
/**
* Create from a link.
*
* @throws InvalidArgumentException
*
* @since 9.4.0
*/
public static function fromLink(Link $link): self
{
return self::create($link->getId(), $link->getName());
}
/**
* Create from an entity.
*
* @throws InvalidArgumentException
*
* @since 9.4.0
*/
public static function fromEntity(Entity $entity): self
{
return self::create($entity->getId());
}
private function clone(): self
{
$obj = new self($this->id);
@@ -103,9 +103,22 @@ class LinkParent
/**
* Create from an entity.
*
* @deprecated Since 9.4.0. Use `fromEntity`.
* @todo Remove in v12.0.
*/
public static function createFromEntity(Entity $entity): self
{
return new self($entity->getEntityType(), $entity->getId());
}
/**
* Create from an entity.
*
* @since 9.4.0
*/
public static function fromEntity(Entity $entity): self
{
return new self($entity->getEntityType(), $entity->getId());
}
}
@@ -33,7 +33,7 @@ use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
use Espo\Core\ORM\Repository\Option\SaveContext;
use Espo\ORM\Repository\Option\SaveContext;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\ORM\Defs\Params\RelationParam;
use RuntimeException;
@@ -36,6 +36,8 @@ use Espo\Core\Hook\Hook\AfterSave;
use Espo\Core\Hook\Hook\AfterUnrelate;
use Espo\Core\Hook\Hook\BeforeRemove;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Core\Hook\Hook\LateAfterRemove;
use Espo\Core\Hook\Hook\LateAfterSave;
use Espo\ORM\Entity;
use Espo\ORM\Query\Select;
use Espo\ORM\Repository\Option\MassRelateOptions;
@@ -52,8 +54,10 @@ class GeneralInvoker
{
private const HOOK_BEFORE_SAVE = 'beforeSave';
private const HOOK_AFTER_SAVE = 'afterSave';
private const HOOK_LATE_AFTER_SAVE = 'lateAfterSave';
private const HOOK_BEFORE_REMOVE = 'beforeRemove';
private const HOOK_AFTER_REMOVE = 'afterRemove';
private const HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove';
private const HOOK_AFTER_RELATE = 'afterRelate';
private const HOOK_AFTER_UNRELATE = 'afterUnrelate';
private const HOOK_AFTER_MASS_RELATE = 'afterMassRelate';
@@ -93,6 +97,16 @@ class GeneralInvoker
return;
}
if ($name === self::HOOK_LATE_AFTER_SAVE && $hook instanceof LateAfterSave) {
if (!$subject instanceof Entity) {
throw new LogicException();
}
$hook->lateAfterSave($subject, SaveOptions::fromAssoc($options));
return;
}
if ($name === self::HOOK_BEFORE_REMOVE && $hook instanceof BeforeRemove) {
if (!$subject instanceof Entity) {
throw new LogicException();
@@ -113,6 +127,16 @@ class GeneralInvoker
return;
}
if ($name === self::HOOK_LATE_AFTER_REMOVE && $hook instanceof LateAfterRemove) {
if (!$subject instanceof Entity) {
throw new LogicException();
}
$hook->lateAfterRemove($subject, RemoveOptions::fromAssoc($options));
return;
}
if ($name === self::HOOK_AFTER_RELATE && $hook instanceof AfterRelate) {
$relationName = $hookData['relationName'] ?? null;
$relatedEntity = $hookData['foreignEntity'] ?? null;
@@ -40,7 +40,7 @@ use Espo\ORM\Repository\Option\RemoveOptions;
interface AfterRemove
{
/**
* Processed after an entity is remove from within a repository.
* Processed after an entity is removed from within a repository.
*
* @param TEntity $entity An entity.
* @param RemoveOptions $options Options.
@@ -40,7 +40,7 @@ use Espo\ORM\Repository\Option\RemoveOptions;
interface BeforeRemove
{
/**
* Processed before an entity is remove from within a repository.
* Processed before an entity is removed from within a repository.
*
* @param TEntity $entity An entity.
* @param RemoveOptions $options Options.
@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Hook\Hook;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\RemoveOptions;
/**
* A lateAfterRemove hook.
*
* @template TEntity of Entity = Entity
*
* @since 9.4.0
*/
interface LateAfterRemove
{
/**
* Processed after an entity is removed, after the transaction (if one is used).
*
* @param TEntity $entity An entity.
* @param RemoveOptions $options Options.
*/
public function lateAfterRemove(Entity $entity, RemoveOptions $options): void;
}
@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Hook\Hook;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
/**
* A lateAfterSave hook.
*
* @template TEntity of Entity = Entity
*
* @since 9.4.0
*/
interface LateAfterSave
{
/**
* Processed after an entity is saved, after the transaction (if one is used).
* To check whether the entity was new before save, obtain the save context
* from the options and call `isNew`.
*
* @param TEntity $entity An entity.
* @param SaveOptions $options Options.
*/
public function lateAfterSave(Entity $entity, SaveOptions $options): void;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
use Espo\Core\HttpClient\Exceptions\ConnectException;
use Espo\Core\HttpClient\Exceptions\NotAllowedInternalHost;
use Espo\Core\HttpClient\Exceptions\TooManyRedirectsException;
use Espo\Core\Utils\Security\UrlCheck;
use GuzzleHttp;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use const CURLE_OPERATION_TIMEDOUT;
class Client
{
private const int MAX_REDIRECT_NUMBER = 5;
/**
* To be instantiated with the ClientFactory.
*
* @internal
*/
public function __construct(
private Options $options,
private UrlCheck $urlCheck,
) {}
/**
* Send a request. Does not throw exceptions on error responses.
*
* @throws TooManyRedirectsException
* @throws ConnectException
*/
public function send(RequestInterface $request): ResponseInterface
{
$options = [
'protocols' => array_map(
fn (Protocol $protocol) => $protocol->value,
$this->options->redirect->protocols
),
'allow_redirects' => false,
'http_errors' => false,
];
if ($this->options->redirect->allow) {
$options['allow_redirects'] = [
'max' => $this->options->redirect->maxNumber ?? self::MAX_REDIRECT_NUMBER,
'strict' => $this->options->redirect->strict,
'protocols' => array_map(
fn (Protocol $protocol) => $protocol->value,
$this->options->redirect->protocols
),
];
}
if ($this->options->timeout !== null) {
$options['timeout'] = $this->options->timeout;
}
if ($this->options->connectTimeout !== null) {
$options['connect_timeout'] = $this->options->connectTimeout;
}
if ($this->options->internalHostRestriction->restrict) {
$stack = GuzzleHttp\HandlerStack::create();
$stack->push(
GuzzleHttp\Middleware::mapRequest(function (RequestInterface $request) {
$url = (string) $request->getUri();
$this->checkUrl($url, $this->options->internalHostRestriction->allowed);
return $request;
})
);
$options['handler'] = $stack;
}
$client = new GuzzleHttp\Client($options);
try {
return $client->send($request);
} catch (GuzzleHttp\Exception\ConnectException $e) {
$context = $e->getHandlerContext();
$reason = null;
if (($context['errno'] ?? 0) === CURLE_OPERATION_TIMEDOUT) {
$reason = ConnectErrorReason::Timeout;
}
throw ConnectException::create(previous: $e, reason: $reason);
} catch (GuzzleHttp\Exception\TooManyRedirectsException $e) {
throw new TooManyRedirectsException(previous: $e);
} catch (GuzzleHttp\Exception\GuzzleException $e) {
throw new RuntimeException(previous: $e);
}
}
/**
* @param string[] $allowed
* @throws NotAllowedInternalHost
*/
private function checkUrl(string $url, array $allowed): void
{
if (
!Util::matchUrlToAddressList($url, $allowed) &&
!$this->urlCheck->isNotInternalUrl($url)
) {
throw new NotAllowedInternalHost("Not allowed internal host in '$url'.");
}
}
}
@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
/**
* An HTTP client factory.
*
* @since 9.4.0
*/
class ClientFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
) {}
public function create(Options $options): Client
{
$binding = BindingContainerBuilder::create()
->bindInstance(Options::class, $options)
->build();
return $this->injectableFactory->createWithBinding(Client::class, $binding);
}
}
@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
enum ConnectErrorReason
{
case Timeout;
}
@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Exceptions;
use Espo\Core\HttpClient\ConnectErrorReason;
use Exception;
final class ConnectException extends SendException
{
private ?ConnectErrorReason $reason = null;
/**
* @internal
*/
public static function create(?Exception $previous, ?ConnectErrorReason $reason): ConnectException
{
$exception = new ConnectException(previous: $previous);
$exception->reason = $reason;
return $exception;
}
public function getReason(): ?ConnectErrorReason
{
return $this->reason;
}
}
@@ -0,0 +1,34 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Exceptions;
class NotAllowedInternalHost extends SendException
{
}
@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Exceptions;
use Exception;
abstract class SendException extends Exception
{}
@@ -0,0 +1,33 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Exceptions;
class TooManyRedirectsException extends SendException
{}
@@ -0,0 +1,50 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
use Espo\Core\HttpClient\Options\InternalHostRestriction;
use Espo\Core\HttpClient\Options\Redirect;
readonly class Options
{
/**
* @todo SSL options.
* Use named parameters when calling.
*
* @param Protocol[] $protocols
*/
public function __construct(
public array $protocols = [Protocol::https, Protocol::http],
public Redirect $redirect = new Redirect(),
public ?int $timeout = null,
public ?int $connectTimeout = null,
public InternalHostRestriction $internalHostRestriction = new InternalHostRestriction(),
) {}
}
@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Options;
class InternalHostRestriction
{
/**
* @param string[] $allowed A list of `host:port`.
*/
public function __construct(
public bool $restrict = false,
public array $allowed = [],
) {}
}
@@ -0,0 +1,47 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient\Options;
use Espo\Core\HttpClient\Protocol;
readonly class Redirect
{
/**
* Use named parameters when calling.
*
* @param Protocol[] $protocols
*/
public function __construct(
public bool $allow = false,
public array $protocols = [Protocol::https],
public ?int $maxNumber = null,
public bool $strict = false,
) {}
}
@@ -0,0 +1,36 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
enum Protocol: string
{
case http = 'http';
case https = 'https';
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\HttpClient;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\StreamInterface;
class Util
{
/**
* @param resource|string|int|float|bool|StreamInterface $resource
* @since 9.4.0
*/
public static function streamFor($resource): StreamInterface
{
return Utils::streamFor($resource);
}
/**
* @internal
* @param string[] $addressList
*/
public static function matchUrlToAddressList(string $url, array $addressList): bool
{
if (!$addressList) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!is_string($host)) {
return false;
}
if (!is_int($port)) {
if ($scheme === 'https') {
$port = 443;
} else if ($scheme === 'http') {
$port = 80;
}
}
if (!is_int($port)) {
return false;
}
$address = $host . ':' . $port;
return in_array($address, $addressList);
}
}
@@ -613,7 +613,7 @@ class DefaultImporter implements Importer
{
foreach ($inlineAttachmentList as $attachment) {
$attachment->setTargetField('body');
$attachment->setRelated(LinkParent::createFromEntity($email));
$attachment->setRelated(LinkParent::fromEntity($email));
$this->entityManager->saveEntity($attachment);
}
@@ -58,6 +58,27 @@ class MailMimeParser implements Parser
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'odt' => 'application/vnd.oasis.opendocument.text',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
'csv' => 'text/csv',
'txt' => 'text/plain',
'html' => 'text/html',
'htm' => 'text/html',
'xml' => 'application/xml',
'zip' => 'application/zip',
'gz' => 'application/gzip',
'eml' => 'message/rfc822',
'svg' => 'image/svg+xml',
'bmp' => 'image/bmp',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
];
private ?WrappeeParser $parser = null;
@@ -434,9 +455,9 @@ class MailMimeParser implements Parser
return null;
}
$ext = explode('.', $filename)[1] ?? null;
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if (!$ext) {
if ($ext === '') {
return null;
}
@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\MassAction\Actions;
use Espo\Core\Acl;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\MassAction\Data;
use Espo\Core\MassAction\MassAction;
use Espo\Core\MassAction\Params;
use Espo\Core\MassAction\QueryBuilder;
use Espo\Core\MassAction\Result;
use Espo\ORM\EntityManager;
use Espo\Tools\Lock\LockService;
/**
* @noinspection PhpUnused
*/
class MassLock implements MassAction
{
public function __construct(
private QueryBuilder $queryBuilder,
private LockService $lockService,
private EntityManager $entityManager,
private Acl $acl,
) {}
public function process(Params $params, Data $data): Result
{
$entityType = $params->getEntityType();
if (!$this->lockService->isEnabled($entityType)) {
throw new ErrorSilent("Not enabled.");
}
if (!$this->lockService->isAllowed($entityType)) {
throw new ForbiddenSilent("Not allowed.");
}
if ($this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES) {
throw new ForbiddenSilent("No mass update permission.");
}
$query = $this->queryBuilder->build($params);
$collection = $this->entityManager
->getRDBRepository($entityType)
->clone($query)
->sth()
->find();
return $this->lockService->massLock($collection);
}
}
@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\MassAction\Actions;
use Espo\Core\Acl;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\MassAction\Data;
use Espo\Core\MassAction\MassAction;
use Espo\Core\MassAction\Params;
use Espo\Core\MassAction\QueryBuilder;
use Espo\Core\MassAction\Result;
use Espo\ORM\EntityManager;
use Espo\Tools\Lock\LockService;
/**
* @noinspection PhpUnused
*/
class MassUnlock implements MassAction
{
public function __construct(
private QueryBuilder $queryBuilder,
private LockService $lockService,
private EntityManager $entityManager,
private Acl $acl,
) {}
public function process(Params $params, Data $data): Result
{
$entityType = $params->getEntityType();
if (!$this->lockService->isEnabled($entityType)) {
throw new ErrorSilent("Not enabled.");
}
if (!$this->lockService->isAllowed($entityType)) {
throw new ForbiddenSilent("Not allowed.");
}
if ($this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES) {
throw new ForbiddenSilent("No mass update permission.");
}
$query = $this->queryBuilder->build($params);
$collection = $this->entityManager
->getRDBRepository($entityType)
->clone($query)
->sth()
->find();
return $this->lockService->massUnlock($collection);
}
}
+1
View File
@@ -49,4 +49,5 @@ class Field
public const EMAIL_ADDRESS = 'emailAddress';
public const PHONE_NUMBER = 'phoneNumber';
public const VERSION_NUMBER = 'versionNumber';
public const string IS_LOCKED = 'isLocked';
}
@@ -127,7 +127,7 @@ class DefaultAssignmentNotificator implements AssignmentNotificator
'userId' => $this->user->getId(),
'userName' => $this->user->getName(),
])
->setRelated(LinkParent::createFromEntity($entity));
->setRelated(LinkParent::fromEntity($entity));
$this->entityManager->saveEntity($notification);
}
@@ -291,6 +291,10 @@ class LinkCheck
{
$entityType = $entity->getEntityType();
if (!$this->acl->checkLink($entityType, $link)) {
throw new ForbiddenSilent("Link $link is forbidden.");
}
/** @var AclTable::ACTION_*|null $action */
$action = $this->getParam($entityType, $link, 'linkRequiredAccess');
@@ -57,7 +57,7 @@ class DefaultActionLogger implements ActionLogger
->setAuthTokenId($this->user->get('authTokenId'))
->setAuthLogRecordId($this->user->get('authLogRecordId'))
->setIpAddress($this->user->get('ipAddress'))
->setTarget(LinkParent::createFromEntity($entity));
->setTarget(LinkParent::fromEntity($entity));
$this->entityManager->saveEntity($historyRecord);
}
@@ -29,12 +29,18 @@
namespace Espo\Core\Record\Deleted;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Field\DateTime;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Repository\Util;
/**
* @implements Restorer<Entity>
@@ -44,12 +50,13 @@ class DefaultRestorer implements Restorer
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private RestorerFactory $restorerFactory,
) {}
public function restore(Entity $entity): void
{
if (!$entity->get(Attribute::DELETED)) {
throw new Forbidden("No 'deleted' attribute.");
throw new Conflict("Entity is not soft-deleted.");
}
$this->entityManager
@@ -57,8 +64,13 @@ class DefaultRestorer implements Restorer
->run(fn () => $this->restoreInTransaction($entity));
}
/**
* @throws Conflict
*/
private function restoreInTransaction(Entity $entity): void
{
$modifiedAt = $this->getModifiedAt($entity);
$repository = $this->entityManager->getRDBRepository($entity->getEntityType());
$repository->restoreDeleted($entity->getId());
@@ -72,5 +84,100 @@ class DefaultRestorer implements Restorer
$entity->set('deleteId', '0');
$repository->save($entity, [SaveOption::SILENT => true]);
}
if ($modifiedAt) {
$this->restoreRelatedRecords($entity, $modifiedAt);
}
}
/**
* @throws Conflict
*/
private function restoreRelatedRecords(Entity $entity, DateTime $modifiedAt): void
{
$relations = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType())
->getRelationList();
foreach ($relations as $relation) {
if (!$relation->getParam(RelationParam::CASCADE_REMOVAL)) {
continue;
}
$this->restoreRelatedLink($entity, $relation, $modifiedAt);
}
}
/**
* @throws Conflict
*/
private function restoreRelatedLink(Entity $entity, RelationDefs $relation, DateTime $modifiedAt): void
{
$foreignEntityType = $relation->tryGetForeignEntityType();
$foreign = $relation->tryGetForeignRelationName();
if (!$foreignEntityType || !$foreign) {
return;
}
$foreignType = $this->entityManager
->getDefs()
->tryGetEntity($foreignEntityType)
?->tryGetRelation($foreign)
?->getType();
if (!$foreignType) {
return;
}
if (!Util::isRelationshipEligibleForCascadeRemoval($relation->getType(), $foreignType)) {
return;
}
$link = $relation->getName();
$builder = SelectBuilder::create()
->from($foreignEntityType)
->withDeleted();
$collection = $this->entityManager
->getRelation($entity, $link)
->clone($builder->build())
->sth()
->where([
Attribute::DELETED => true,
Field::MODIFIED_AT . '>=' => $modifiedAt->toString(),
])
->find();
foreach ($collection as $relatedEntity) {
$this->restoreRelated($relatedEntity);
}
}
/**
* @throws Conflict
*/
private function restoreRelated(Entity $relatedEntity): void
{
if (
!$relatedEntity->hasAttribute(Attribute::DELETED) ||
!$relatedEntity->get(Attribute::DELETED)
) {
return;
}
$restorer = $this->restorerFactory->create($relatedEntity->getEntityType());
$restorer->restore($relatedEntity);
}
private function getModifiedAt(Entity $entity): ?DateTime
{
$modifiedAtString = $entity->get(Field::MODIFIED_AT);
return $modifiedAtString ? DateTime::fromString($modifiedAtString) : null;
}
}
@@ -30,17 +30,15 @@
namespace Espo\Core\Record\Deleted;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface Restorer
{
/**
* @param TEntity $entity A deleted entity.
* @throws Forbidden
* @throws Conflict
*/
public function restore(Entity $entity): void;
@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Record\Deleted;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
class RestorerFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private User $user,
) {}
public function create(string $entityType): Restorer
{
/** @var class-string<Restorer<Entity>> $restorerClassName */
$restorerClassName = $this->metadata->get("recordDefs.$entityType.deletedRestorerClassName") ??
DefaultRestorer::class;
/** @var Restorer */
return $this->injectableFactory->createWithBinding($restorerClassName, $this->createBinding());
}
private function createBinding(): BindingContainer
{
return BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->build();
}
}
@@ -134,7 +134,15 @@ class EntityDuplicator
{
$type = $fieldDefs->getType();
if (in_array($type, [FieldType::AUTOINCREMENT, FieldType::NUMBER])) {
if (
in_array($type, [
FieldType::AUTOINCREMENT,
FieldType::NUMBER,
FieldType::LINK_ONE,
// Additional measure. It's not fetched.
FieldType::PASSWORD,
])
) {
return true;
}
@@ -36,7 +36,7 @@ use Espo\ORM\Entity;
use Espo\Core\Record\CreateParams;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface CreateHook
{
@@ -36,7 +36,7 @@ use Espo\ORM\Entity;
use Espo\Core\Record\DeleteParams;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface DeleteHook
{
@@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface LinkHook
{
@@ -97,7 +97,10 @@ class Provider
$key = $type . 'HookClassNameList';
/** @var class-string[] $classNameList */
$classNameList = $this->metadata->get(['recordDefs', $entityType, $key]) ?? [];
$classNameList = [
...$this->metadata->get("app.record.$key", []),
...$this->metadata->get("recordDefs.$entityType.$key", [])
];
$interfaces = $this->typeInterfaceListMap[$type] ?? null;
@@ -33,7 +33,7 @@ use Espo\ORM\Entity;
use Espo\Core\Record\ReadParams;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface ReadHook
{
@@ -37,7 +37,7 @@ use Espo\ORM\Entity;
/**
* On record create or update.
*
* @template TEntity of Entity
* @template TEntity of Entity = Entity
* @since 8.1.0.
*/
interface SaveHook
@@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface UnlinkHook
{
@@ -37,7 +37,7 @@ use Espo\ORM\Entity;
use Espo\Core\Record\UpdateParams;
/**
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
interface UpdateHook
{
@@ -95,7 +95,10 @@ class FilterProvider
private function getCreateClassNameList(string $entityType): array
{
/** @var class-string<Filter>[] */
return $this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList") ?? [];
return [
...$this->metadata->get("app.record.createInputFilterClassNameList", []),
...$this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList", [])
];
}
/**
@@ -104,6 +107,9 @@ class FilterProvider
private function getUpdateClassNameList(string $entityType): array
{
/** @var class-string<Filter>[] */
return $this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList") ?? [];
return [
...$this->metadata->get("app.record.updateInputFilterClassNameList", []),
...$this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList", [])
];
}
}
@@ -69,6 +69,9 @@ class FilterProvider
private function getClassNameList(string $entityType): array
{
/** @var class-string<Filter<Entity>>[] */
return $this->metadata->get("recordDefs.$entityType.outputFilterClassNameList") ?? [];
return [
...$this->metadata->get("app.record.outputFilterClassNameList", []),
...$this->metadata->get("recordDefs.$entityType.outputFilterClassNameList", []),
];
}
}
+12 -9
View File
@@ -42,7 +42,8 @@ use Espo\Core\FieldSanitize\SanitizeManager;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Repository\Option\RemoveOption;
use Espo\Core\ORM\Repository\Option\SaveContext;
use Espo\Core\Record\Deleted\RestorerFactory;
use Espo\ORM\Repository\Option\SaveContext;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\Access\LinkCheck;
@@ -51,8 +52,6 @@ use Espo\Core\Record\ActionHistory\ActionLogger;
use Espo\Core\Record\ConcurrencyControl\OptimisticProcessor;
use Espo\Core\Record\Defaults\Populator as DefaultsPopulator;
use Espo\Core\Record\Defaults\PopulatorFactory as DefaultsPopulatorFactory;
use Espo\Core\Record\Deleted\DefaultRestorer;
use Espo\Core\Record\Deleted\Restorer;
use Espo\Core\Record\DynamicLogic\InputFilterProcessor;
use Espo\Core\Record\Formula\Processor as FormulaProcessor;
use Espo\Core\Record\Input\Data;
@@ -580,7 +579,7 @@ class Service implements Crud,
->getFieldList();
foreach ($fieldDefsList as $fieldDefs) {
if (!$fieldDefs->getParam('readOnlyAfterCreate')) {
if (!$fieldDefs->getParam(FieldParam::READ_ONLY_AFTER_CREATE)) {
continue;
}
@@ -976,12 +975,16 @@ class Service implements Crud,
throw new NotFound();
}
/** @var class-string<Restorer<Entity>> $restorerClassName */
$restorerClassName = $this->metadata->get("recordDefs.$this->entityType.deletedRestorerClassName") ??
DefaultRestorer::class;
if (!$entity->hasAttribute(Attribute::DELETED)) {
throw new Forbidden("Record type is not restorable.");
}
/** @var Restorer<Entity> $restorer */
$restorer = $this->injectableFactory->createWithBinding($restorerClassName, $this->createBinding());
if ($entity->get(Attribute::DELETED) !== true) {
throw new Conflict("Record type is not soft-deleted.");
}
$factory = $this->injectableFactory->createWithBinding(RestorerFactory::class, $this->createBinding());
$restorer = $factory->create($this->entityType);
$restorer->restore($entity);
}
@@ -40,8 +40,57 @@ use Espo\ORM\Query\Part\Order;
*/
class CategoryTree extends Database
{
private const ATTR_ORDER = 'order';
private const ATTR_PARENT_ID = 'parentId';
private const string ATTR_ORDER = 'order';
private const string ATTR_PARENT_ID = 'parentId';
private const string PATH_ATTR_ASCENDOR_ID = 'ascendorId';
private const string PATH_ATTR_DESCENDOR_ID = 'descendorId';
/**
* @inheritDoc
*/
public function save(Entity $entity, array $options = []): void
{
$this->entityManager
->getTransactionManager()
->run(function () use ($entity, $options) {
if (!$entity->isNew() && $entity->isAttributeChanged(self::ATTR_PARENT_ID)) {
$this->lockPathEntries($entity);
}
parent::save($entity, $options);
});
}
/**
* @inheritDoc
*/
public function remove(Entity $entity, array $options = []): void
{
$this->entityManager
->getTransactionManager()
->run(function () use ($entity, $options) {
$this->lockPathEntries($entity);
parent::remove($entity, $options);
});
}
private function lockPathEntries(Entity $entity): void
{
$this->entityManager
->getRDBRepository($this->getPathEntityType())
->sth()
->select(Attribute::ID)
->where([
'OR' => [
self::PATH_ATTR_ASCENDOR_ID => $entity->getId(),
self::PATH_ATTR_DESCENDOR_ID => $entity->getId(),
]
])
->forUpdate()
->find();
}
protected function beforeSave(Entity $entity, array $options = [])
{
@@ -64,7 +113,7 @@ class CategoryTree extends Database
$em = $this->entityManager;
$pathEntityType = $entity->getEntityType() . 'Path';
$pathEntityType = $this->getPathEntityType();
if ($entity->isNew()) {
if ($parentId) {
@@ -173,7 +222,7 @@ class CategoryTree extends Database
{
parent::afterRemove($entity, $options);
$pathEntityType = $entity->getEntityType() . 'Path';
$pathEntityType = $this->getPathEntityType();
$em = $this->entityManager;
@@ -215,4 +264,9 @@ class CategoryTree extends Database
$entity->set(self::ATTR_ORDER, $order);
}
private function getPathEntityType(): string
{
return $this->entityType . 'Path';
}
}
@@ -97,6 +97,8 @@ class Database extends RDBRepository
$this->recordIdGenerator = $recordIdGenerator;
$this->hooksDisabled = $this->hooksDisabled || $metadata->get("entityDefs.$entityType.hooksDisabled");
$this->transactionalSave = $this->transactionalSave ||
$metadata->get("entityDefs.$entityType.transactionalSave");
$hookMediator = null;
@@ -126,6 +128,17 @@ class Database extends RDBRepository
* @param array<string, mixed> $options
*/
public function save(Entity $entity, array $options = []): void
{
$this->prepareSaveInternal($entity, $options);
parent::save($entity, $options);
}
/**
* @param TEntity $entity
* @param array<string, mixed> $options
*/
private function prepareSaveInternal(Entity $entity, array $options = []): void
{
if (
$entity->isNew() &&
@@ -140,8 +153,28 @@ class Database extends RDBRepository
}
$this->restoreData = [];
}
parent::save($entity, $options);
/**
* @param TEntity $entity
* @param array<string, mixed> $options
*/
final protected function lateAfterSave(Entity $entity, array $options): void
{
if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) {
$this->hookManager->process($this->entityType, 'lateAfterSave', $entity, $options);
}
}
/**
* @param TEntity $entity
* @param array<string, mixed> $options
*/
final protected function lateAfterRemove(Entity $entity, array $options): void
{
if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) {
$this->hookManager->process($this->entityType, 'lateAfterRemove', $entity, $options);
}
}
/**
@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Session;
use const PHP_SESSION_NONE;
/**
* Do not use directly. Require the Session interface instead.
*
* @internal
*/
class DefaultSession implements Session
{
public function __construct(
?string $cacheLimiter = null,
?int $cacheExpire = null,
) {
if (session_status() === PHP_SESSION_NONE) {
if ($cacheLimiter !== null) {
session_cache_limiter($cacheLimiter);
}
if ($cacheExpire !== null) {
session_cache_expire($cacheExpire);
}
session_start();
}
}
public function get(string $key): mixed
{
return $_SESSION[$key] ?? null;
}
public function set(string $key, mixed $value): Session
{
$_SESSION[$key] = $value;
return $this;
}
public function clear(string $key): void
{
unset($_SESSION[$key]);
}
public function clearAll(): void
{
session_unset();
}
public function has(string $key): bool
{
return array_key_exists($key, $_SESSION);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Session;
/**
* A session wrapper.
*
* @since 9.4.0
*/
interface Session
{
public function get(string $key): mixed;
public function set(string $key, mixed $value): self;
public function clear(string $key): void;
public function clearAll(): void;
public function has(string $key): bool;
}
@@ -32,4 +32,6 @@ namespace Espo\Core\Templates\Entities;
use Espo\Core\Entities\CategoryTreeItem;
class CategoryTree extends CategoryTreeItem
{}
{
public const string TEMPLATE_TYPE = 'CategoryTree';
}
@@ -9,6 +9,13 @@
"link": "#{entityType}/list",
"acl": "read",
"style": "default"
},
{
"labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}",
"link": "#{subjectEntityType}",
"acl": "read",
"style": "default",
"aclScope": "{subjectEntityType}"
}
]
},
@@ -19,6 +26,13 @@
"link": "#{entityType}",
"acl": "read",
"style": "default"
},
{
"labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}",
"link": "#{subjectEntityType}",
"acl": "read",
"style": "default",
"aclScope": "{subjectEntityType}"
}
]
}
@@ -29,4 +43,4 @@
"unlinkDisabled": true
}
}
}
}
@@ -1,10 +1,26 @@
{
"entity": true,
"layouts": true,
"tab": true,
"tab": false,
"acl": true,
"aclPortal": true,
"customizable": true,
"aclLevelList": [
"all",
"team",
"no"
],
"aclPortalLevelList": [
"all",
"no"
],
"importable": false,
"notifications": false
}
"notifications": false,
"customizable": false,
"entityManager": {
"fields": false,
"formula": false,
"relationships": false,
"addField": false,
"edit": false,
"layouts": false
}
}
@@ -53,19 +53,5 @@
"forcePatchAttributeDependencyMap": {
"dateEnd": ["dateStart"],
"dateEndDate": ["dateStartDate"]
},
"dynamicLogic":{
"fields":{
"duration": {
"readOnly": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "isAllDay"
}
]
}
}
}
}
}
@@ -0,0 +1,14 @@
{
"fields":{
"duration": {
"readOnly": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "isAllDay"
}
]
}
}
}
}
@@ -0,0 +1,5 @@
{
"labels": {
"Create {entityType}": "Create {entityTypeTranslated}"
}
}
@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\V9_4;
use Espo\Core\Upgrades\Migration\Script;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
class AfterUpgrade implements Script
{
public function __construct(
private EntityManager $entityManager,
) {}
public function run(): void
{
$this->updatePreferences();
}
private function updatePreferences(): void
{
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->sth()
->where([
User::ATTR_IS_ACTIVE => true,
User::ATTR_TYPE => [
User::TYPE_ADMIN,
User::TYPE_REGULAR,
User::TYPE_PORTAL,
]
])
->find();
foreach ($users as $user) {
$preferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($user->getId());
if (!$preferences) {
continue;
}
$preferences->set('notificationGrouping', true);
$this->entityManager->saveEntity($preferences);
}
}
}
@@ -52,7 +52,11 @@ class RelationConverter
{
private const DEFAULT_VARCHAR_LENGTH = 255;
/** @var string[] */
/**
* Parameters to merge from both sides.
*
* @var string[]
*/
private $mergeParams = [
RelationParam::RELATION_NAME,
RelationParam::CONDITIONS,
@@ -118,8 +122,14 @@ class RelationConverter
$raw = $convertedEntityDefs->toAssoc();
if (isset($raw[EntityParam::RELATIONS][$name])) {
$this->mergeParams($raw[EntityParam::RELATIONS][$name], $params, $foreignParams ?? [], $linkType);
$this->correct($raw[EntityParam::RELATIONS][$name]);
$defs = &$raw[EntityParam::RELATIONS][$name];
$this->mergeParams($defs, $params, $foreignParams ?? [], $linkType);
$this->correct($defs);
if ($params[RelationParam::CASCADE_REMOVAL] ?? false) {
$defs[RelationParam::CASCADE_REMOVAL] = true;
}
}
return [$entityType => $raw];
@@ -33,6 +33,7 @@ use Espo\Core\Utils\DataUtil;
use Espo\Core\Utils\Metadata\AdditionalBuilder;
use Espo\Core\Utils\Metadata\BuilderHelper;
use Espo\Core\Utils\Util;
use RuntimeException;
use stdClass;
class Fields implements AdditionalBuilder
@@ -75,6 +76,10 @@ class Fields implements AdditionalBuilder
}
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
if (!is_object($fieldDefsItem)) {
throw new RuntimeException("Bad definition for $entityType.$field field.");
}
$additionalFields = $this->builderHelper->getAdditionalFields(
field: $field,
params: Util::objectToArray($fieldDefsItem),
@@ -57,6 +57,25 @@ class Builder
['app', 'client', 'scriptList'],
['app', 'client', 'cssList'],
['app', 'client', 'linkList'],
['app', 'record', 'selectApplierClassNameList'],
['app', 'record', 'createInputFilterClassNameList'],
['app', 'record', 'updateInputFilterClassNameList'],
['app', 'record', 'outputFilterClassNameList'],
['app', 'record', 'beforeReadHookClassNameList'],
['app', 'record', 'earlyBeforeCreateHookClassNameList'],
['app', 'record', 'beforeCreateHookClassNameList'],
['app', 'record', 'earlyBeforeUpdateHookClassNameList'],
['app', 'record', 'beforeUpdateHookClassNameList'],
['app', 'record', 'beforeDeleteHookClassNameList'],
['app', 'record', 'afterCreateHookClassNameList'],
['app', 'record', 'afterUpdateHookClassNameList'],
['app', 'record', 'afterDeleteHookClassNameList'],
['app', 'record', 'beforeLinkHookClassNameList'],
['app', 'record', 'beforeUnlinkHookClassNameList'],
['app', 'record', 'afterLinkHookClassNameList'],
['app', 'record', 'afterUnlinkHookClassNameList'],
['recordDefs', self::ANY_KEY, 'readLoaderClassNameList'],
['recordDefs', self::ANY_KEY, 'listLoaderClassNameList'],
['recordDefs', self::ANY_KEY, 'saverClassNameList'],
+7
View File
@@ -578,6 +578,8 @@ class Util
*
* @param mixed $var1
* @param mixed $var2
* @deprecated As of v9.4.
* @todo Remove in v10.0.
*/
public static function areEqual($var1, $var2): bool
{
@@ -596,6 +598,8 @@ class Util
* Sort array recursively.
*
* @param array<string|int, mixed> $array
* @deprecated As of v9.4.
* @todo Remove in v10.0.
*/
public static function ksortRecursive(&$array): bool
{
@@ -786,9 +790,12 @@ class Util
/**
* Check whether values are equal.
* Not to be used for comparing user input values.
*
* @param mixed $v1
* @param mixed $v2
*
* @internal
*/
public static function areValuesEqual($v1, $v2, bool $isUnordered = false): bool
{
+2 -27
View File
@@ -29,6 +29,7 @@
namespace Espo\Core\Webhook;
use Espo\Core\HttpClient\Util;
use Espo\Core\Utils\Config;
/**
@@ -48,32 +49,6 @@ class AddressUtil
/** @var string[] $allowedAddressList */
$allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? [];
if (!$allowedAddressList) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!is_string($host)) {
return false;
}
if (!is_int($port)) {
if ($scheme === 'https') {
$port = 443;
} else if ($scheme === 'http') {
$port = 80;
}
}
if (!is_int($port)) {
return false;
}
$address = $host . ':' . $port;
return in_array($address, $allowedAddressList);
return Util::matchUrlToAddressList($url, $allowedAddressList);
}
}
+9 -2
View File
@@ -283,7 +283,11 @@ class Queue
} catch (Exception $e) {
$this->failQueueItemList($itemList, true);
$this->log->error("Webhook Queue: Webhook '{$webhook->getId()}' sending failed. Error: {$e->getMessage()}");
$this->log->error("Webhook Queue: Webhook '{id}' sending failed; {message}", [
'exception' => $e,
'id' => $webhook->getId(),
'message' => $e->getMessage(),
]);
return;
}
@@ -305,7 +309,10 @@ class Queue
protected function logSending(Webhook $webhook, int $code): void
{
$this->log->debug("Webhook Queue: Webhook '{$webhook->getId()}' sent, response code: $code.");
$this->log->debug("Webhook Queue: Webhook '{id}' sent, response code: {code}.", [
'id' => $webhook->getId(),
'code' => $code,
]);
}
/**
+123 -84
View File
@@ -34,19 +34,23 @@ use Espo\Core\Utils\Config;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Security\UrlCheck;
use Espo\Entities\Webhook;
use Espo\Core\HttpClient;
use GuzzleHttp\Psr7\Request;
use LogicException;
use Psr\Http\Message\RequestInterface;
/**
* Sends a portion.
*/
class Sender
{
private const CONNECT_TIMEOUT = 5;
private const TIMEOUT = 10;
private const int CONNECT_TIMEOUT = 5;
private const int TIMEOUT = 10;
public function __construct(
private Config $config,
private UrlCheck $urlCheck,
private AddressUtil $addressUtil,
private HttpClient\ClientFactory $clientFactory,
) {}
/**
@@ -57,91 +61,44 @@ class Sender
{
$payload = Json::encode($dataList);
$signature = null;
$legacySignature = null;
[$signature, $legacySignature] = $this->prepareSignatures($webhook, $payload);
$secretKey = $webhook->getSecretKey();
$options = new HttpClient\Options(
protocols: [HttpClient\Protocol::https, HttpClient\Protocol::http],
redirect: new HttpClient\Options\Redirect(
allow: true,
protocols: [HttpClient\Protocol::https],
),
timeout: $this->getTimeout(),
connectTimeout: $this->getConnectTimeout(),
internalHostRestriction: new HttpClient\Options\InternalHostRestriction(
restrict: true,
allowed: $this->getAllowedAddressList(),
),
);
if ($secretKey) {
$signature = $this->buildSignature($webhook, $payload, $secretKey);
$legacySignature = $this->buildSignatureLegacy($webhook, $payload, $secretKey);
$request = $this->prepareRequest(
url: $this->getUrl($webhook),
payload: $payload,
signature: $signature,
legacySignature: $legacySignature,
);
$client = $this->clientFactory->create($options);
try {
$response = $client->send($request);
} catch (HttpClient\Exceptions\ConnectException $e) {
if ($e->getReason() === HttpClient\ConnectErrorReason::Timeout) {
return 408;
}
throw new Error("Connect error.", previous: $e);
} catch (HttpClient\Exceptions\TooManyRedirectsException $e) {
throw new Error("Too many redirects.", previous: $e);
}
$connectTimeout = $this->config->get('webhookConnectTimeout', self::CONNECT_TIMEOUT);
$timeout = $this->config->get('webhookTimeout', self::TIMEOUT);
$headerList = [];
$headerList[] = 'Content-Type: application/json';
$headerList[] = 'Content-Length: ' . strlen($payload);
if ($signature) {
$headerList[] = 'Signature: ' . $signature;
}
if ($legacySignature) {
$headerList[] = 'X-Signature: ' . $legacySignature;
}
$url = $webhook->getUrl();
if (!$url) {
throw new Error("Webhook does not have URL.");
}
if (!$this->urlCheck->isUrl($url)) {
throw new Error("'$url' is not valid URL.");
}
if (
!$this->addressUtil->isAllowedUrl($url) &&
!$this->urlCheck->isNotInternalUrl($url)
) {
throw new Error("URL '$url' points to an internal host, not allowed.");
}
$handler = curl_init($url);
if ($handler === false) {
throw new Error("Could not init CURL for URL {$url}.");
}
curl_setopt($handler, \CURLOPT_RETURNTRANSFER, true);
curl_setopt($handler, \CURLOPT_FOLLOWLOCATION, true);
curl_setopt($handler, \CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($handler, \CURLOPT_HEADER, true);
curl_setopt($handler, \CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($handler, \CURLOPT_CONNECTTIMEOUT, $connectTimeout);
curl_setopt($handler, \CURLOPT_TIMEOUT, $timeout);
curl_setopt($handler, \CURLOPT_PROTOCOLS, \CURLPROTO_HTTPS | \CURLPROTO_HTTP);
curl_setopt($handler, \CURLOPT_REDIR_PROTOCOLS, \CURLPROTO_HTTPS);
curl_setopt($handler, \CURLOPT_HTTPHEADER, $headerList);
curl_setopt($handler, \CURLOPT_POSTFIELDS, $payload);
curl_exec($handler);
$code = curl_getinfo($handler, \CURLINFO_HTTP_CODE);
if (!is_numeric($code)) {
$code = 0;
}
if (!is_int($code)) {
$code = intval($code);
}
$errorNumber = curl_errno($handler);
if (
$errorNumber &&
in_array($errorNumber, [\CURLE_OPERATION_TIMEDOUT, \CURLE_OPERATION_TIMEOUTED])
) {
$code = 408;
}
curl_close($handler);
return $code;
return $response->getStatusCode();
}
private function buildSignature(Webhook $webhook, string $payload, string $secretKey): string
@@ -159,4 +116,86 @@ class Sender
{
return base64_encode($webhook->getId() . ':' . hash_hmac('sha256', $payload, $secretKey, true));
}
/**
* @return string
* @throws Error
*/
private function getUrl(Webhook $webhook): string
{
$url = $webhook->getUrl() ?? throw new Error("Webhook does not have URL.");
if (!$this->urlCheck->isUrl($url)) {
throw new Error("'$url' is not valid URL.");
}
return $url;
}
/**
* @return string[]
*/
private function getAllowedAddressList(): array
{
/** @var string[] $allowedAddressList */
$allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? [];
return $allowedAddressList;
}
private function prepareRequest(
string $url,
string $payload,
?string $signature,
?string $legacySignature,
): RequestInterface {
$request = (new Request('POST', $url))
->withHeader('Content-Type', 'application/json')
->withHeader('Content-Length', (string) strlen($payload));
if ($signature) {
$request = $request->withHeader('Signature', $signature);
}
if ($legacySignature) {
$request = $request->withHeader('X-Signature', $legacySignature);
}
$request = $request->withBody(HttpClient\Util::streamFor($payload));
if (!$request instanceof RequestInterface) {
throw new LogicException();
}
return $request;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private function prepareSignatures(Webhook $webhook, string $payload): array
{
$signature = null;
$legacySignature = null;
$secretKey = $webhook->getSecretKey();
if ($secretKey) {
$signature = $this->buildSignature($webhook, $payload, $secretKey);
$legacySignature = $this->buildSignatureLegacy($webhook, $payload, $secretKey);
}
return [$signature, $legacySignature];
}
private function getConnectTimeout(): ?int
{
return $this->config->get('webhookConnectTimeout', self::CONNECT_TIMEOUT);
}
private function getTimeout(): ?int
{
return $this->config->get('webhookTimeout', self::TIMEOUT);
}
}
+20
View File
@@ -64,6 +64,16 @@ class Note extends Entity
public const TYPE_EMAIL_RECEIVED = 'EmailReceived';
public const TYPE_EMAIL_SENT = 'EmailSent';
public const string DATA_ATTR_ADDED_ASSIGNED_USERS = 'addedAssignedUsers';
public const string DATA_ATTR_REMOVED_ASSIGNED_USERS = 'removedAssignedUsers';
public const string DATA_ATTR_ASSIGNED_USERS = 'assignedUsers';
public const string DATA_ATTR_STATUS_VALUE = 'statusValue';
public const string DATA_ATTR_STATUS_FIELD = 'statusField';
public const string DATA_ATTR_ASSIGNED_USER_ID = 'assignedUserId';
public const string DATA_ATTR_ASSIGNED_USER_NAME = 'assignedUserName';
public const string DATA_ATTR_FIELDS = 'fields';
public const string DATA_ATTR_ATTRIBUTES = 'attributes';
private bool $aclIsProcessed = false;
public function isPost(): bool
@@ -341,6 +351,16 @@ class Note extends Entity
return $this;
}
/**
* @since 9.4.0
*/
public function setIsInternal(bool $isInternal): self
{
$this->set('isInternal', $isInternal);
return $this;
}
public function getParent(): ?OrmEntity
{
return $this->relations->getOne(Field::PARENT);
@@ -54,6 +54,22 @@ class Notification extends Entity
public const ATTR_ACTION_ID = 'actionId';
public const ATTR_NUMBER = 'number';
public const string ATTR_RELATED_TYPE = 'relatedType';
public const string ATTR_RELATED_ID = 'relatedId';
public const string ATTR_RELATED_PARENT_TYPE = 'relatedParentType';
public const string ATTR_RELATED_PARENT_ID = 'relatedParentId';
public const string FIELD_DATA = 'data';
public const string FIELD_MESSAGE = 'message';
public const string FIELD_TYPE = 'type';
public const string FIELD_RELATED_PARENT = 'relatedParent';
public const string FIELD_IS_FEATURED = 'isFeatured';
public const string GROUP_TYPE_NOTE = Notification::TYPE_NOTE;
public const string GROUP_TYPE_EMAIL_RECEIVED = Notification::TYPE_EMAIL_RECEIVED;
public const string DATE_ATTR_NOTE_ID = 'noteId';
public function getType(): ?string
{
return $this->get('type');
@@ -128,6 +144,12 @@ class Notification extends Entity
return $this;
}
public function getRelatedParent(): ?LinkParent
{
/** @var ?LinkParent */
return $this->getValueObject('relatedParent');
}
public function setRelatedParent(LinkParent|Entity|null $relatedParent): self
{
if ($relatedParent instanceof LinkParent) {
@@ -185,8 +207,27 @@ class Notification extends Entity
return $this->get('actionId');
}
/**
* @internal
*/
public function setGroupedCount(?int $groupedCount): self
{
return $this->set('groupedCount', $groupedCount);
}
/**
* @internal
*/
public function getGroupType(): ?string
{
return $this->get('groupType');
}
/**
* @since 9.4.0
*/
public function setIsFeatured(bool $isFeatured): self
{
return $this->set(self::FIELD_IS_FEATURED, $isFeatured);
}
}
@@ -30,7 +30,7 @@
namespace Espo\Hooks\Common;
use Espo\Core\Hook\Hook\AfterSave;
use Espo\Core\ORM\Repository\Option\SaveContext;
use Espo\ORM\Repository\Option\SaveContext;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Exceptions\Conflict;
use Espo\Core\Hook\Hook\BeforeRemove;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use Espo\ORM\Exceptions\ValidationException;
use Espo\ORM\Repository\Option\RemoveOptions;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Tools\Lock\LockValidationHelper;
use Espo\Tools\Lock\LockMetadataProvider;
/**
* @noinspection PhpUnused
*/
class ValidateLocked implements BeforeSave, BeforeRemove
{
public static int $order = 12;
public function __construct(
private LockMetadataProvider $lockMetadataProvider,
private LockValidationHelper $lockValidationHelper,
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
return;
}
try {
$this->lockValidationHelper->validateBeforeSave($entity);
} catch (Conflict $e) {
throw new ValidationException(previous: $e);
}
}
public function beforeRemove(Entity $entity, RemoveOptions $options): void
{
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
return;
}
try {
$this->lockValidationHelper->validateBeforeRemove($entity);
} catch (Conflict $e) {
throw new ValidationException(previous: $e);
}
if ($entity->get(Field::IS_LOCKED)) {
throw new ValidationException("Cannot remove a locked record.");
}
}
}
@@ -30,7 +30,7 @@
namespace Espo\Hooks\Note;
use Espo\Core\Hook\Hook\AfterSave;
use Espo\Core\ORM\Repository\Option\SaveContext;
use Espo\ORM\Repository\Option\SaveContext;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Tools\Notification\HookProcessor\Params;
@@ -151,7 +151,7 @@ class Invitations
->setSubject($subject)
->setBody($body)
->setIsHtml()
->setParent(LinkParent::createFromEntity($entity));
->setParent(LinkParent::fromEntity($entity));
$attachmentName = ucwords($this->language->translateLabel($entity->getEntityType(), 'scopeNames')) . '.ics';
@@ -195,7 +195,7 @@ class Invitations
$terminateAt = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
}
$uid->setTarget(LinkParent::createFromEntity($entity));
$uid->setTarget(LinkParent::fromEntity($entity));
$uid->setTerminateAt(DateTimeField::fromString($terminateAt));
$this->entityManager->saveEntity($uid);
@@ -114,7 +114,7 @@ class Meeting implements AssignmentNotificator
$notification
->setType(self::NOTIFICATION_TYPE_EVENT_ATTENDEE)
->setUserId($userId)
->setRelated(LinkParent::createFromEntity($entity))
->setRelated(LinkParent::fromEntity($entity))
->setData([
'entityType' => $entity->getEntityType(),
'entityId' => $entity->getId(),
@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Modules\Crm\Classes\FieldValidators\Meeting\ExternalService;
use Espo\Core\FieldValidation\Validator;
use Espo\Core\FieldValidation\Validator\Data;
use Espo\Core\FieldValidation\Validator\Failure;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\ORM\Entity;
/**
* @implements Validator<Meeting>
*/
class Valid implements Validator
{
public function __construct(
private Metadata $metadata,
) {}
public function validate(Entity $entity, string $field, Data $data): ?Failure
{
$service = $entity->getExternalService();
if (!$service) {
return null;
}
if ($this->metadata->get("app.meetingServices.$service.enabled")) {
return null;
}
return Failure::create();
}
}
@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Modules\Crm\Classes\RecordHooks\Meeting;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\Modules\Crm\Tools\Meeting\MeetingServiceAvailabilityCheckerFactory;
use Espo\ORM\Entity;
/**
* @implements SaveHook<Meeting>
*/
class BeforeCreateExternalServiceCheck implements SaveHook
{
public function __construct(
private Metadata $metadata,
private MeetingServiceAvailabilityCheckerFactory $factory,
private User $user,
) {}
public function process(Entity $entity): void
{
$service = $entity->getExternalService();
if (!$service) {
return;
}
if (!$this->metadata->get("app.meetingServices.$service.enabled")) {
throw new BadRequest("Not supported service '$service'.");
}
$checker = $this->factory->create($service);
if (!$checker->check($this->user)) {
throw new Forbidden("Not allowed service '$service'.");
}
}
}
@@ -40,6 +40,16 @@ class Contact extends Person
{
public const ENTITY_TYPE = 'Contact';
/** @since v9.3.0. */
public const string ATTR_ACCOUNT_ID = 'accountId';
/** @since v9.3.0. */
public const string FIELD_ACCOUNTS = 'accounts';
/** @since v9.3.0. */
public const string FIELD_ACCOUNT = 'account';
/** @since v9.3.0. */
public const string RELATIONSHIP_ACCOUNT_CONTACT = 'AccountContact';
/** @since v9.3.0. */
public const string COLUMN_ACCOUNTS_ROLE = 'role';
@@ -61,6 +71,27 @@ class Contact extends Person
return $this->relations->getOne('account');
}
/**
* Get accounts as link-multiple.
*
* @since 9.4.0
*/
public function getAccountsLinkMultiple(): LinkMultiple
{
/** @var LinkMultiple */
return $this->getValueObject('accounts');
}
/**
* Set accounts.
*
* @since 9.4.0
*/
public function setAccounts(LinkMultiple $accounts): self
{
return $this->setValueObject(self::FIELD_ACCOUNTS, $accounts);
}
/**
* Get accounts.
*
@@ -247,4 +247,12 @@ class Meeting extends Entity
{
return $this->get('joinUrl');
}
/**
* @since 9.4.0
*/
public function getExternalService(): ?string
{
return $this->get('externalService');
}
}
@@ -29,36 +29,49 @@
namespace Espo\Modules\Crm\Hooks\Contact;
use Espo\Core\Field\Link;
use Espo\Core\Field\LinkMultipleItem;
use Espo\Core\Hook\Hook\AfterRelate;
use Espo\Core\Hook\Hook\AfterSave;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Repository\Option\RelateOptions;
use Espo\ORM\Repository\Option\SaveOptions;
/**
* @implements BeforeSave<Contact>
* @implements AfterSave<Contact>
* @implements AfterRelate<Contact>
*/
class Accounts implements AfterSave
class Accounts implements BeforeSave, AfterSave, AfterRelate
{
private const string COLUMN_ROLE = Contact::COLUMN_ACCOUNTS_ROLE;
private const string ATTR_TITLE = 'title';
public function __construct(private EntityManager $entityManager) {}
/**
* @param Contact $entity
*/
public function beforeSave(Entity $entity, SaveOptions $options): void
{
$this->setPrimary($entity);
$this->restoreAccounts($entity);
}
public function afterSave(Entity $entity, SaveOptions $options): void
{
$accountIdChanged = $entity->isAttributeChanged('accountId');
$titleChanged = $entity->isAttributeChanged('title');
$accountIdChanged = $entity->isAttributeChanged(Contact::ATTR_ACCOUNT_ID);
$titleChanged = $entity->isAttributeChanged(self::ATTR_TITLE);
/** @var ?string $fetchedAccountId */
$fetchedAccountId = $entity->getFetched('accountId');
$fetchedAccountId = $entity->getFetched(Contact::ATTR_ACCOUNT_ID);
$accountId = $entity->getAccount()?->getId();
$title = $entity->getTitle();
$relation = $this->entityManager
->getRDBRepositoryByClass(Contact::class)
->getRelation($entity, 'accounts');
->getRelation($entity, Contact::FIELD_ACCOUNTS);
if (!$accountId && $fetchedAccountId) {
$relation->unrelateById($fetchedAccountId);
@@ -75,8 +88,8 @@ class Accounts implements AfterSave
}
$accountContact = $this->entityManager
->getRDBRepository('AccountContact')
->select(['role'])
->getRDBRepository(Contact::RELATIONSHIP_ACCOUNT_CONTACT)
->select([self::COLUMN_ROLE])
->where([
'accountId' => $accountId,
'contactId' => $entity->getId(),
@@ -85,13 +98,79 @@ class Accounts implements AfterSave
->findOne();
if (!$accountContact && $accountIdChanged) {
$relation->relateById($accountId, ['role' => $title]);
$relation->relateById($accountId, [self::COLUMN_ROLE => $title]);
return;
}
if ($titleChanged && $accountContact && $title !== $accountContact->get('role')) {
$relation->updateColumnsById($accountId, ['role' => $title]);
if ($titleChanged && $accountContact && $title !== $accountContact->get(self::COLUMN_ROLE)) {
$relation->updateColumnsById($accountId, [self::COLUMN_ROLE => $title]);
}
}
private function setPrimary(Contact $entity): void
{
if (
!$entity->isAttributeChanged(Contact::ATTR_ACCOUNT_ID) &&
!$entity->isAttributeChanged(Contact::FIELD_ACCOUNTS . 'Ids')
) {
return;
}
if (!$entity->getAccount() && $entity->getAccountsLinkMultiple()->getList()) {
$first = $entity->getAccountsLinkMultiple()->getList()[0];
$entity->setAccount(Link::create($first->getId(), $first->getName()));
}
}
/**
* When the form is saved but there is no the accounts, the columns is still sent. Restore the accounts then.
*/
private function restoreAccounts(Contact $entity): void
{
$account = $entity->getAccount();
if (!$account || !$entity->isAttributeChanged(Contact::FIELD_ACCOUNTS . 'Columns')) {
return;
}
$accounts = $entity->getAccountsLinkMultiple();
if ($accounts->hasId($account->getId())) {
return;
}
$accounts = $accounts->withAdded(
LinkMultipleItem::fromEntity($account)->withName($account->getName())
->withColumnValue(self::COLUMN_ROLE, null)
);
$entity->setAccounts($accounts);
}
public function afterRelate(
Entity $entity,
string $relationName,
Entity $relatedEntity,
array $columnData,
RelateOptions $options,
): void {
if ($relationName !== Contact::FIELD_ACCOUNTS) {
return;
}
if ($entity->getAccount()) {
return;
}
$relation = $this->entityManager->getRelation($entity, Contact::FIELD_ACCOUNT);
if ($relation->findOne()) {
return;
}
$relation->relate($relatedEntity);
}
}

Some files were not shown because too many files have changed in this diff Show More