Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Resource\FileReader;
/**
* @noinspection PhpUnused
*/
class GetAbout implements Action
{
public function __construct(
private FileReader $fileReader,
private Config\SystemConfig $systemConfig,
) {}
public function process(Request $request): Response
{
$text = $this->fileReader->read('texts/about.md', FileReader\Params::create());
return ResponseComposer::json([
'text' => $text,
'version' => $this->systemConfig->getVersion(),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\InjectableFactory;
use Espo\Tools\App\AppService as Service;
/**
* Gets user data.
*/
class GetUser implements Action
{
public function __construct(private InjectableFactory $injectableFactory) {}
public function process(Request $request): Response
{
$data = $this->injectableFactory
->create(Service::class)
->getUserData();
return ResponseComposer::json($data);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Authentication\AuthenticationFactory;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Json;
class PostDestroyAuthToken implements Action
{
public function __construct(private AuthenticationFactory $authenticationFactory) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$token = $data->token ?? null;
if (!$token || !is_string($token)) {
throw new BadRequest("No `token`.");
}
$authentication = $this->authenticationFactory->create();
$response = ResponseComposer::empty();
try {
$authentication->destroyAuthToken($token, $request, $response);
} catch (NotFound) {
return $response->writeBody(Json::encode(false));
}
return $response->writeBody(Json::encode(true));
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
/**
* App parameter to be passed to the frontend.
*
* @see https://docs.espocrm.com/development/app-params/
*/
interface AppParam
{
public function get(): mixed;
}

View File

@@ -0,0 +1,515 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider;
use Espo\Core\Mail\ConfigDataProvider as EmailConfigDataProvider;
use Espo\Core\Name\Field;
use Espo\Core\Name\Link;
use Espo\Core\Utils\SystemUser;
use Espo\Entities\DashboardTemplate;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailAddress;
use Espo\Entities\InboundEmail;
use Espo\Entities\Settings;
use Espo\ORM\Name\Attribute;
use Espo\Core\Acl;
use Espo\Core\Authentication\Logins\Espo;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Entities\Preferences;
use Espo\ORM\EntityManager;
use stdClass;
use Throwable;
class AppService
{
/** @var string[] */
private array $forbiddenUserAttributeList = [
'apiKey',
'authTokenId',
'password',
'rolesIds',
'rolesNames',
];
/** @var string[] */
private array $allowedUserAttributeList = [
'type',
];
/** @var string[] */
private array $allowedInternalUserAttributeList = [
'teamsIds',
'defaultTeamId',
'defaultTeamName',
];
/** @var string[] */
private array $allowedPortalUserAttributeList = [
'contactId',
'contactName',
'accountId',
'accountsIds',
];
public function __construct(
private Config $config,
private EntityManager $entityManager,
private Metadata $metadata,
private Acl $acl,
private InjectableFactory $injectableFactory,
private SettingsService $settingsService,
private User $user,
private Preferences $preferences,
private FieldUtil $fieldUtil,
private Log $log,
private AuthenticationMethodProvider $authenticationMethodProvider,
private SystemUser $systemUser,
private EmailConfigDataProvider $emailConfigDataProvider,
) {}
/**
* @return array<string, mixed>
*/
public function getUserData(): array
{
$preferencesData = $this->preferences->getValueMap();
$this->filterPreferencesData($preferencesData);
$user = $this->user;
if (!$user->has('teamsIds')) {
$user->loadLinkMultipleField(Field::TEAMS);
}
if ($user->isPortal()) {
$user->loadAccountField();
$user->loadLinkMultipleField('accounts');
}
$settings = $this->settingsService->getConfigData();
$dashboardTemplateId = $user->get('dashboardTemplateId');
if ($dashboardTemplateId) {
$dashboardTemplate = $this->entityManager
->getEntityById(DashboardTemplate::ENTITY_TYPE, $dashboardTemplateId);
if ($dashboardTemplate) {
$settings->forcedDashletsOptions = $dashboardTemplate->get('dashletsOptions') ?? (object) [];
$settings->forcedDashboardLayout = $dashboardTemplate->get('layout') ?? [];
}
}
$language = Language::detectLanguage($this->config, $this->preferences);
return [
'user' => $this->getUserDataForFrontend(),
'acl' => $this->getAclDataForFrontend(),
'preferences' => $preferencesData,
'token' => $this->user->get('token'),
'settings' => $settings,
'language' => $language,
'appParams' => $this->getAppParams(),
];
}
/**
* @return array<string, mixed>
*/
private function getAppParams(): array
{
$user = $this->user;
$auth2FARequired =
$user->isRegular() &&
$this->config->get('auth2FA') &&
$this->config->get('auth2FAForced') &&
!$user->get('auth2FA');
$authenticationMethod = $this->authenticationMethodProvider->get();
$passwordChangeForNonAdminDisabled = $authenticationMethod !== Espo::NAME;
$logoutWait = (bool) $this->metadata->get(['authenticationMethods', $authenticationMethod, 'logoutClassName']);
$timeZoneList = $this->metadata
->get(['entityDefs', Settings::ENTITY_TYPE, 'fields', 'timeZone', 'options']) ?? [];
$appParams = [
'maxUploadSize' => $this->getMaxUploadSize() / 1024.0 / 1024.0,
'isRestrictedMode' => $this->config->get('restrictedMode'),
'passwordChangeForNonAdminDisabled' => $passwordChangeForNonAdminDisabled,
'timeZoneList' => $timeZoneList,
'auth2FARequired' => $auth2FARequired,
'logoutWait' => $logoutWait,
'systemUserId' => $this->systemUser->getId(),
];
/** @var array<string, array<string, mixed>> $map */
$map = $this->metadata->get(['app', 'appParams']) ?? [];
foreach ($map as $paramKey => $item) {
/** @var ?class-string<AppParam> $className */
$className = $item['className'] ?? null;
if (!$className) {
continue;
}
try {
/** @var AppParam $obj */
$obj = $this->injectableFactory->create($className);
$itemParams = $obj->get();
} catch (Throwable $e) {
$this->log->error("AppParam $paramKey: " . $e->getMessage(), ['exception' => $e]);
continue;
}
$appParams[$paramKey] = $itemParams;
}
return $appParams;
}
private function getUserDataForFrontend(): stdClass
{
$user = $this->user;
$data = $user->getValueMap();
$emailAddressData = $this->getEmailAddressData();
$data->emailAddressList = $emailAddressData['emailAddressList'];
$data->userEmailAddressList = $emailAddressData['userEmailAddressList'];
$data->excludeFromReplyEmailAddressList = $emailAddressData['excludeFromReplyEmailAddressList'];
foreach ($this->forbiddenUserAttributeList as $attribute) {
unset($data->$attribute);
}
$forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList(User::ENTITY_TYPE);
$isPortal = $user->isPortal();
foreach ($forbiddenAttributeList as $attribute) {
if (in_array($attribute, $this->allowedUserAttributeList)) {
continue;
}
if ($isPortal && in_array($attribute, $this->allowedPortalUserAttributeList)) {
continue;
}
if (!$isPortal && in_array($attribute, $this->allowedInternalUserAttributeList)) {
continue;
}
unset($data->$attribute);
}
return $data;
}
private function getAclDataForFrontend(): stdClass
{
$data = $this->acl->getMapData();
if (!$this->user->isAdmin()) {
$data = unserialize(serialize($data));
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['scopes'], []));
foreach ($scopeList as $scope) {
if (!$this->acl->check($scope)) {
unset($data->table->$scope);
unset($data->fieldTable->$scope);
unset($data->fieldTableQuickAccess->$scope);
}
}
}
return $data;
}
/**
* @return array{
* emailAddressList: string[],
* userEmailAddressList: string[],
* excludeFromReplyEmailAddressList: string[],
* }
*/
private function getEmailAddressData(): array
{
$user = $this->user;
$systemIsShared = $this->emailConfigDataProvider->isSystemOutboundAddressShared();
$systemAddress = $this->emailConfigDataProvider->getSystemOutboundAddress();
$addressList = [];
$userAddressList = [];
/** @var iterable<EmailAddress> $emailAddresses */
$emailAddresses = $this->entityManager
->getRelation($user, Link::EMAIL_ADDRESSES)
->find();
foreach ($emailAddresses as $emailAddress) {
if ($emailAddress->isInvalid()) {
continue;
}
$userAddressList[] = $emailAddress->getAddress();
if ($user->getEmailAddress() === $emailAddress->getAddress()) {
continue;
}
$addressList[] = $emailAddress->getAddress();
}
if ($user->getEmailAddress()) {
array_unshift($addressList, $user->getEmailAddress());
}
if (!$systemIsShared) {
$addressList = $this->filterUserEmailAddressList($user, $addressList);
}
$addressList = array_merge($addressList, $this->getUserGroupEmailAddressList($user));
if ($systemIsShared && $systemAddress) {
$addressList[] = $systemAddress;
}
$addressList = array_values(array_unique($addressList));
return [
'emailAddressList' => $addressList,
'userEmailAddressList' => $userAddressList,
'excludeFromReplyEmailAddressList' => $this->getExcludeFromReplyAddressList(),
];
}
/**
* @param string[] $emailAddressList
* @return string[]
*/
private function filterUserEmailAddressList(User $user, array $emailAddressList): array
{
$emailAccountCollection = $this->entityManager
->getRDBRepositoryByClass(EmailAccount::class)
->select([
Attribute::ID,
Field::EMAIL_ADDRESS,
])
->where([
'assignedUserId' => $user->getId(),
'useSmtp' => true,
'status' => EmailAccount::STATUS_ACTIVE,
])
->find();
$inAccountList = array_map(
fn (EmailAccount $e) => $e->getEmailAddress(),
[...$emailAccountCollection]
);
return array_values(array_filter(
$emailAddressList,
fn (string $item) => in_array($item, $inAccountList)
));
}
/**
* @return string[]
*/
private function getUserGroupEmailAddressList(User $user): array
{
$groupEmailAccountPermission = $this->acl->getPermissionLevel(Acl\Permission::GROUP_EMAIL_ACCOUNT);
if (!$groupEmailAccountPermission || $groupEmailAccountPermission === Acl\Table::LEVEL_NO) {
return [];
}
if ($groupEmailAccountPermission === Acl\Table::LEVEL_TEAM) {
$teamIdList = $user->getLinkMultipleIdList(Field::TEAMS);
if (!count($teamIdList)) {
return [];
}
$inboundEmailList = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
'teamsMiddle.teamId' => $teamIdList,
])
->join(Field::TEAMS)
->distinct()
->find();
$list = [];
foreach ($inboundEmailList as $inboundEmail) {
if (!$inboundEmail->getEmailAddress()) {
continue;
}
$list[] = $inboundEmail->getEmailAddress();
}
return $list;
}
if ($groupEmailAccountPermission === Acl\Table::LEVEL_ALL) {
$inboundEmailList = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
])
->find();
$list = [];
foreach ($inboundEmailList as $inboundEmail) {
if (!$inboundEmail->getEmailAddress()) {
continue;
}
$list[] = $inboundEmail->getEmailAddress();
}
return $list;
}
return [];
}
/**
* @return int
*/
private function getMaxUploadSize()
{
$maxSize = 0;
$postMaxSize = $this->convertPHPSizeToBytes(ini_get('post_max_size'));
if ($postMaxSize > 0) {
$maxSize = $postMaxSize;
}
return $maxSize;
}
/**
* @param string|false $size
* @return int
*/
private function convertPHPSizeToBytes($size)
{
if (is_numeric($size)) {
return (int) $size;
}
if ($size === false) {
return 0;
}
$suffix = strtoupper(substr($size, -1));
$value = (int) substr($size, 0, -1);
if ($suffix == 'P') {
$value *= pow(1024, 5);
} else if ($suffix == 'T') {
$value *= pow(1024, 4);
} else if ($suffix == 'G') {
$value *= pow(1024, 3);
} else if ($suffix == 'M') {
$value *= pow(1024, 2);
} elseif ($suffix == 'K') {
$value *= 1024;
}
return $value;
}
private function filterPreferencesData(stdClass $data): void
{
$passwordFieldList = $this->fieldUtil->getFieldByTypeList(Preferences::ENTITY_TYPE, 'password');
foreach ($passwordFieldList as $field) {
unset($data->$field);
}
}
/**
* @return string[]
*/
private function getExcludeFromReplyAddressList(): array
{
if (!$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
return [];
}
/** @var iterable<InboundEmail> $accounts */
$accounts = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->select('emailAddress')
->where(['excludeFromReply' => true])
->find();
$list = [];
foreach ($accounts as $account) {
if (!$account->getEmailAddress()) {
continue;
}
$list[] = $account->getEmailAddress();
}
return $list;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Jobs;
use Espo\Core\DataManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\JobDataLess;
class ClearCache implements JobDataLess
{
private DataManager $dataManager;
public function __construct(DataManager $dataManager)
{
$this->dataManager = $dataManager;
}
/**
* @throws Error
*/
public function run(): void
{
$this->dataManager->clearCache();
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Jobs;
use Espo\Core\DataManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\JobDataLess;
class Rebuild implements JobDataLess
{
private DataManager $dataManager;
public function __construct(DataManager $dataManager)
{
$this->dataManager = $dataManager;
}
/**
* @throws Error
*/
public function run(): void
{
$this->dataManager->rebuild();
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Language;
class AclDependencyItem
{
/**
* @param ?string[] $anyScopeList
*/
public function __construct(
private string $target,
private ?array $anyScopeList,
private ?string $scope,
private ?string $field
) {}
/**
* A language path to be allowed if a user has access to a specific scope/field.
*/
public function getTarget(): string
{
return $this->target;
}
/**
* @return ?string[]
*/
public function getAnyScopeList(): ?array
{
return $this->anyScopeList;
}
public function getScope(): ?string
{
return $this->scope;
}
public function getField(): ?string
{
return $this->field;
}
}

View File

@@ -0,0 +1,216 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Language;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\SystemConfig;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
class AclDependencyProvider
{
private const CACHE_KEY = 'languageAclDependency';
/** @var string[] */
private array $enumFieldTypeList = [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
];
/** @var ?AclDependencyItem[] */
private ?array $data = null;
private bool $useCache;
public function __construct(
private DataCache $dataCache,
private Metadata $metadata,
private Defs $ormDefs,
SystemConfig $systemConfig,
) {
$this->useCache = $systemConfig->useCache();
}
/**
* @return AclDependencyItem[]
*/
public function get(): array
{
if ($this->data === null) {
$this->data = $this->loadData();
}
return $this->data;
}
/**
* @return AclDependencyItem[]
*/
private function loadData(): array
{
if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) {
/** @var array<string, mixed>[] $raw */
$raw = $this->dataCache->get(self::CACHE_KEY);
return $this->buildFromRaw($raw);
}
return $this->buildData();
}
/**
* @return AclDependencyItem[]
*/
private function buildData(): array
{
$data = [];
foreach (($this->metadata->get(['app', 'language', 'aclDependencies']) ?? []) as $target => $item) {
$anyScopeList = $item['anyScopeList'] ?? null;
$scope = $item['scope'] ?? null;
$field = $item['field'] ?? null;
$data[] = [
'target' => $target,
'anyScopeList' => $anyScopeList,
'scope' => $scope,
'field' => $field,
];
}
foreach ($this->ormDefs->getEntityList() as $entityDefs) {
if (!$this->metadata->get(['scopes', $entityDefs->getName(), 'object'])) {
continue;
}
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$item = $this->getDataFromField($entityDefs->getName(), $fieldDefs);
if ($item) {
$data[] = $item;
}
}
}
if ($this->useCache) {
$this->dataCache->store(self::CACHE_KEY, $data);
}
return $this->buildFromRaw($data);
}
/**
* @return ?array<string, mixed>
*/
private function getDataFromField(string $entityType, Defs\FieldDefs $fieldDefs): ?array
{
if ($fieldDefs->getType() === FieldType::FOREIGN) {
$refEntityType = $fieldDefs->getParam('link') ?
$this->ormDefs
->getEntity($entityType)
->tryGetRelation($fieldDefs->getParam('link'))
?->tryGetForeignEntityType() :
null;
$refField = $fieldDefs->getParam('field');
if (!$refEntityType || !$refField) {
return null;
}
$foreignFieldType = $this->ormDefs
->tryGetEntity($refEntityType)
?->tryGetField($refField)
?->getType();
if (
!in_array($foreignFieldType, [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
])
) {
return null;
}
return [
'target' => "$refEntityType.options.$refField",
'anyScopeList' => null,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
if (!in_array($fieldDefs->getType(), $this->enumFieldTypeList)) {
return null;
}
$optionsReference = $fieldDefs->getParam('optionsReference');
if (!$optionsReference || !str_contains($optionsReference, '.')) {
return null;
}
[$refEntityType, $refField] = explode('.', $optionsReference);
$target = "$refEntityType.options.$refField";
return [
'target' => $target,
'anyScopeList' => null,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
/**
* @param array<string, mixed>[] $raw
* @return AclDependencyItem[]
*/
private function buildFromRaw(array $raw): array
{
$list = [];
foreach ($raw as $rawItem) {
$target = $rawItem['target'] ?? null;
$anyScopeList = $rawItem['anyScopeList'] ?? null;
$scope = $rawItem['scope'] ?? null;
$field = $rawItem['field'] ?? null;
$list[] = new AclDependencyItem($target, $anyScopeList, $scope, $field);
}
return $list;
}
}

View File

@@ -0,0 +1,272 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
use Espo\Core\Utils\Language as LanguageUtil;
use Espo\Core\Acl;
use Espo\Core\Container;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Tools\App\Language\AclDependencyProvider;
class LanguageService
{
public function __construct(
private Metadata $metadata,
private Acl $acl,
private User $user,
private AclDependencyProvider $aclDependencyProvider,
private Container $container
) {}
// @todo Use proxy.
protected function getDefaultLanguage(): LanguageUtil
{
/** @var LanguageUtil */
return $this->container->get('defaultLanguage');
}
protected function getLanguage(): LanguageUtil
{
/** @var LanguageUtil */
return $this->container->get('language');
}
/**
* @return array<string, mixed>
*/
public function getDataForFrontendFromLanguage(LanguageUtil $language): array
{
$data = $language->getAll();
if ($this->user->isSystem()) {
unset($data['Global']['scopeNames']);
unset($data['Global']['scopeNamesPlural']);
unset($data['Global']['dashlets']);
unset($data['Global']['links']);
foreach ($data as $k => $item) {
if (
in_array($k, ['Global', 'User', 'Campaign']) ||
$this->metadata->get(['scopes', $k, 'languageIsGlobal'])
) {
continue;
}
unset($data[$k]);
}
unset($data['User']['fields']);
unset($data['User']['links']);
unset($data['User']['options']);
unset($data['User']['filters']);
unset($data['User']['presetFilters']);
unset($data['User']['boolFilters']);
unset($data['User']['tooltips']);
unset($data['Campaign']['fields']);
unset($data['Campaign']['links']);
unset($data['Campaign']['options']);
unset($data['Campaign']['tooltips']);
unset($data['Campaign']['presetFilters']);
} else if (!$this->user->isAdmin()) {
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['scopes'], []));
foreach ($scopeList as $scope) {
if (!$this->metadata->get(['scopes', $scope, 'entity'])) {
continue;
}
if ($this->metadata->get(['scopes', $scope, 'languageAclDisabled'])) {
continue;
}
if (!$this->acl->tryCheck($scope)) {
unset($data[$scope]);
unset($data['Global']['scopeNames'][$scope]);
unset($data['Global']['scopeNamesPlural'][$scope]);
} else {
if (in_array($scope, ['EmailAccount', 'InboundEmail'])) {
continue;
}
foreach ($this->acl->getScopeForbiddenFieldList($scope) as $field) {
if (isset($data[$scope]['fields'])) {
unset($data[$scope]['fields'][$field]);
}
if (isset($data[$scope]['options'])) {
unset($data[$scope]['options'][$field]);
}
if (isset($data[$scope]['links'])) {
unset($data[$scope]['links'][$field]);
}
}
$this->unsetEmpty($data, $scope);
}
}
if (!$this->user->isAdmin()) {
$this->prepareDataNonAdmin($data, $language);
}
}
$data['User']['fields'] = $data['User']['fields'] ?? [];
$data['User']['fields']['password'] = $language->translate('password', 'fields', 'User');
$data['User']['fields']['passwordConfirm'] = $language->translate('passwordConfirm', 'fields', 'User');
$data['User']['fields']['newPassword'] = $language->translate('newPassword', 'fields', 'User');
$data['User']['fields']['newPasswordConfirm'] = $language->translate('newPasswordConfirm', 'fields', 'User');
return $data;
}
/**
* @return array<string, mixed>
*/
public function getDataForFrontend(bool $default = false): array
{
if ($default) {
$languageObj = $this->getDefaultLanguage();
} else {
$languageObj = $this->getLanguage();
}
return $this->getDataForFrontendFromLanguage($languageObj);
}
/**
* @param array<string, mixed> $data
*/
private function unsetEmpty(array &$data, string $scope): void
{
if (($data[$scope]['options'] ?? null) === []) {
unset($data[$scope]['options']);
}
if (($data[$scope]['fields'] ?? null) === []) {
unset($data[$scope]['fields']);
}
if (($data[$scope]['links'] ?? null) === []) {
unset($data[$scope]['links']);
}
}
/**
* @param array<string, mixed> $data
*/
private function prepareDataNonAdmin(array &$data, LanguageUtil $languageObj): void
{
unset($data['Admin']);
unset($data['LayoutManager']);
unset($data['EntityManager']);
unset($data['FieldManager']);
unset($data['Settings']);
unset($data['ApiUser']);
unset($data['DynamicLogic']);
$data['Settings'] = [
'options' => [
'auth2FAMethodList' => $languageObj->get(['Settings', 'options', 'auth2FAMethodList']),
],
];
$data['Admin'] = [
'messages' => [
'userHasNoEmailAddress' => $languageObj->translate('userHasNoEmailAddress', 'messages', 'Admin'),
],
];
foreach ($this->aclDependencyProvider->get() as $dependencyItem) {
$target = $dependencyItem->getTarget();
$aclScope = $dependencyItem->getScope();
$aclField = $dependencyItem->getField();
$anyScopeList = $dependencyItem->getAnyScopeList();
$targetArr = explode('.', $target);
$isFullScope = !str_contains($target, '.');
if ($isFullScope && isset($data[$target])) {
continue;
}
if ($anyScopeList) {
$skip = true;
foreach ($anyScopeList as $itemScope) {
if ($this->acl->tryCheck($itemScope)) {
$skip = false;
break;
}
}
if ($skip) {
continue;
}
}
if ($aclScope) {
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if ($aclField && in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))) {
continue;
}
}
$pointer =& $data;
foreach ($targetArr as $i => $k) {
if ($i === count($targetArr) - 1) {
$pointer[$k] = $languageObj->get($targetArr);
break;
}
if (!isset($pointer[$k])) {
$pointer[$k] = [];
}
$pointer =& $pointer[$k];
}
if ($isFullScope) {
$this->unsetEmpty($data, $target);
}
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Metadata;
class AclDependencyItem
{
/**
* @param ?string[] $anyScopeList
*/
public function __construct(
private string $target,
private ?string $scope,
private ?string $field,
private ?array $anyScopeList = null,
) {}
/**
* A metadata path to be allowed if a user has access to a specific scope/field.
*/
public function getTarget(): string
{
return $this->target;
}
public function getScope(): ?string
{
return $this->scope;
}
public function getField(): ?string
{
return $this->field;
}
/**
* @return ?string[]
* @since 9.2.5
*/
public function getAnyScopeList(): ?array
{
return $this->anyScopeList;
}
}

View File

@@ -0,0 +1,209 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App\Metadata;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
class AclDependencyProvider
{
private const CACHE_KEY = 'metadataAclDependency';
/** @var string[] */
private array $enumFieldTypeList = [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
];
/** @var ?AclDependencyItem[] */
private ?array $data = null;
private bool $useCache;
public function __construct(
private DataCache $dataCache,
private Metadata $metadata,
private Defs $ormDefs,
Config\SystemConfig $systemConfig,
) {
$this->useCache = $systemConfig->useCache();
}
/**
* @return AclDependencyItem[]
*/
public function get(): array
{
if ($this->data === null) {
$this->data = $this->loadData();
}
return $this->data;
}
/**
* @return AclDependencyItem[]
*/
private function loadData(): array
{
if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) {
/** @var array<string, mixed>[] $raw */
$raw = $this->dataCache->get(self::CACHE_KEY);
return $this->buildFromRaw($raw);
}
return $this->buildData();
}
/**
* @return AclDependencyItem[]
*/
private function buildData(): array
{
$data = [];
foreach (($this->metadata->get(['app', 'metadata', 'aclDependencies']) ?? []) as $target => $item) {
$anyScopeList = $item['anyScopeList'] ?? null;
$scope = $item['scope'] ?? null;
$field = $item['field'] ?? null;
$data[] = [
'target' => $target,
'anyScopeList' => $anyScopeList,
'scope' => $scope,
'field' => $field,
];
}
foreach ($this->ormDefs->getEntityList() as $entityDefs) {
if (!$this->metadata->get(['scopes', $entityDefs->getName(), 'object'])) {
continue;
}
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$item = $this->getDataFromField($entityDefs->getName(), $fieldDefs);
if ($item) {
$data[] = $item;
}
}
}
if ($this->useCache) {
$this->dataCache->store(self::CACHE_KEY, $data);
}
return $this->buildFromRaw($data);
}
/**
* @return ?array<string, mixed>
*/
private function getDataFromField(string $entityType, Defs\FieldDefs $fieldDefs): ?array
{
if ($fieldDefs->getType() === FieldType::FOREIGN) {
$refEntityType = $fieldDefs->getParam('link') ?
$this->ormDefs
->getEntity($entityType)
->tryGetRelation($fieldDefs->getParam('link'))
?->tryGetForeignEntityType() :
null;
$refField = $fieldDefs->getParam('field');
if (!$refEntityType || !$refField) {
return null;
}
return [
'target' => "entityDefs.$refEntityType.fields.$refField",
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
if (!in_array($fieldDefs->getType(), $this->enumFieldTypeList)) {
return null;
}
$optionsPath = $fieldDefs->getParam('optionsPath');
$optionsReference = $fieldDefs->getParam('optionsReference');
if (
!$optionsPath &&
$optionsReference &&
str_contains($optionsReference, '.')
) {
[$refEntityType, $refField] = explode('.', $optionsReference);
$optionsPath = "entityDefs.$refEntityType.fields.$refField.options";
}
if (!$optionsPath) {
return null;
}
return [
'target' => $optionsPath,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
/**
* @param array<string, mixed>[] $raw
* @return AclDependencyItem[]
*/
private function buildFromRaw(array $raw): array
{
$list = [];
foreach ($raw as $rawItem) {
$target = $rawItem['target'] ?? null;
$scope = $rawItem['scope'] ?? null;
$field = $rawItem['field'] ?? null;
$anyScopeList = $rawItem['anyScopeList'] ?? null;
$list[] = new AclDependencyItem(
target: $target,
scope: $scope,
field: $field,
anyScopeList: $anyScopeList,
);
}
return $list;
}
}

View File

@@ -0,0 +1,296 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
use Espo\Core\Acl;
use Espo\Core\Utils\Metadata as MetadataUtil;
use Espo\Core\Utils\ObjectUtil;
use Espo\Core\Utils\Util;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\Tools\App\Metadata\AclDependencyProvider;
use stdClass;
class MetadataService
{
private const ANY_KEY = '__ANY__';
public function __construct(
private Acl $acl,
private MetadataUtil $metadata,
private User $user,
private AclDependencyProvider $aclDependencyProvider
) {}
public function getDataForFrontend(): stdClass
{
$data = $this->metadata->getAll();
$hiddenPathList = $this->metadata->get(['app', 'metadata', 'frontendHiddenPathList'], []);
foreach ($hiddenPathList as $row) {
$this->removeDataByPath($row, $data);
}
if ($this->user->isAdmin()) {
return $data;
}
$data = ObjectUtil::clone($data);
$hiddenPathList = $this->metadata->get(['app', 'metadata', 'frontendNonAdminHiddenPathList'], []);
foreach ($hiddenPathList as $row) {
$this->removeDataByPath($row, $data);
}
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['entityDefs'], []));
foreach ($scopeList as $scope) {
$isEntity = $this->metadata->get(['scopes', $scope, 'entity']);
if ($isEntity === false) {
continue;
}
if ($scope === Reminder::ENTITY_TYPE) {
continue;
}
$isAllowed = $isEntity !== null && $this->acl->tryCheck($scope);
if (!$isAllowed) {
unset($data->entityDefs->$scope);
unset($data->clientDefs->$scope);
unset($data->entityAcl->$scope);
unset($data->scopes->$scope);
unset($data->logicDefs->$scope);
}
}
$entityTypeList = array_keys(get_object_vars($data->entityDefs));
foreach ($entityTypeList as $entityType) {
$linksDefs = $this->metadata->get(['entityDefs', $entityType, 'links'], []);
$forbiddenFieldList = $this->acl->getScopeForbiddenFieldList($entityType);
foreach ($linksDefs as $link => $defs) {
$type = $defs['type'] ?? null;
$hasField = (bool) $this->metadata->get(['entityDefs', $entityType, 'fields', $link]);
if ($type === 'belongsToParent') {
if ($hasField) {
$parentEntityList = $this->metadata
->get(['entityDefs', $entityType, 'fields', $link, 'entityList']);
if (is_array($parentEntityList)) {
foreach ($parentEntityList as $i => $e) {
if (!$this->acl->tryCheck($e)) {
unset($parentEntityList[$i]);
}
}
$parentEntityList = array_values($parentEntityList);
$data->entityDefs->$entityType->fields->$link->entityList = $parentEntityList;
}
}
continue;
}
$foreignEntityType = $defs['entity'] ?? null;
if ($foreignEntityType) {
if ($this->acl->tryCheck($foreignEntityType)) {
continue;
}
if ($this->user->isPortal()) {
if ($foreignEntityType === 'Account' || $foreignEntityType === 'Contact') {
continue;
}
}
}
if ($hasField) {
if (!in_array($link, $forbiddenFieldList)) {
continue;
}
unset($data->entityDefs->$entityType->fields->$link);
}
unset($data->entityDefs->$entityType->links->$link);
if (isset($data->clientDefs->$entityType->relationshipPanels)) {
unset($data->clientDefs->$entityType->relationshipPanels->$link);
}
}
}
unset($data->entityDefs->Settings);
/** @var string[] $dashletList */
$dashletList = array_keys($this->metadata->get(['dashlets'], []));
foreach ($dashletList as $item) {
$aclScope = $this->metadata->get(['dashlets', $item, 'aclScope']);
if ($aclScope && !$this->acl->tryCheck($aclScope)) {
unset($data->dashlets->$item);
}
}
unset($data->authenticationMethods);
unset($data->formula);
foreach ($this->aclDependencyProvider->get() as $dependencyItem) {
$aclScope = $dependencyItem->getScope();
$aclField = $dependencyItem->getField();
$anyScopeList = $dependencyItem->getAnyScopeList();
if ($anyScopeList) {
$skip = true;
foreach ($anyScopeList as $itemScope) {
if ($this->acl->tryCheck($itemScope)) {
$skip = false;
break;
}
}
if ($skip) {
continue;
}
}
if ($aclScope) {
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if ($aclField && in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))) {
continue;
}
}
$targetArr = explode('.', $dependencyItem->getTarget());
$pointer = $data;
$value = $this->metadata->getObjects($targetArr);
if ($value === null) {
// Important.
continue;
}
foreach ($targetArr as $i => $k) {
if ($i === count($targetArr) - 1) {
$pointer->$k = $value;
break;
}
if (!isset($pointer->$k)) {
$pointer->$k = (object) [];
}
$pointer = $pointer->$k;
}
}
return $data;
}
/**
*
* @param string[] $row
* @param stdClass $data
*/
private function removeDataByPath($row, &$data): void
{
$p = &$data;
$path = [&$p];
foreach ($row as $i => $item) {
if (is_array($item)) {
break;
}
if ($item === self::ANY_KEY) {
foreach (get_object_vars($p) as &$v) {
$this->removeDataByPath(
array_slice($row, $i + 1),
$v
);
}
return;
}
if (!property_exists($p, $item)) {
break;
}
if ($i == count($row) - 1) {
unset($p->$item);
$o = &$p;
for ($j = $i - 1; $j > 0; $j--) {
if (is_object($o) && !count(get_object_vars($o))) {
$o = &$path[$j];
$k = $row[$j];
unset($o->$k);
} else {
break;
}
}
} else {
$p = &$p->$item;
$path[] = &$p;
}
}
}
public function getDataForFrontendByKey(?string $key): mixed
{
$data = $this->getDataForFrontend();
return Util::getValueByKey($data, $key);
}
}

View File

@@ -0,0 +1,232 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
use Espo\Core\Name\Field;
use Espo\ORM\EntityManager;
use Espo\Repositories\Preferences as Repository;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\Utils\Config;
use stdClass;
class PreferencesService
{
private EntityManager $entityManager;
private User $user;
private Acl $acl;
private Config $config;
private FieldValidationManager $fieldValidationManager;
public function __construct(
EntityManager $entityManager,
User $user,
Acl $acl,
Config $config,
FieldValidationManager $fieldValidationManager
) {
$this->entityManager = $entityManager;
$this->user = $user;
$this->acl = $acl;
$this->config = $config;
$this->fieldValidationManager = $fieldValidationManager;
}
/**
* @throws Forbidden
*/
protected function processAccessCheck(string $userId): void
{
if (!$this->user->isAdmin()) {
if ($this->user->getId() !== $userId) {
throw new Forbidden();
}
}
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function read(string $userId): Preferences
{
$this->processAccessCheck($userId);
/** @var ?Preferences $entity */
$entity = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$entity || !$user) {
throw new NotFound();
}
$entity->set(Field::NAME, $user->getName());
$entity->set('isPortalUser', $user->isPortal());
// @todo Remove.
$entity->clear('smtpPassword');
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_READ);
foreach ($forbiddenAttributeList as $attribute) {
$entity->clear($attribute);
}
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
public function update(string $userId, stdClass $data): Preferences
{
$this->processAccessCheck($userId);
if ($this->acl->getLevel(Preferences::ENTITY_TYPE, Table::ACTION_EDIT) === Table::LEVEL_NO) {
throw new Forbidden();
}
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_EDIT);
foreach ($forbiddenAttributeList as $attribute) {
unset($data->$attribute);
}
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
/** @var ?Preferences $entity */
$entity = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$entity || !$user) {
throw new NotFound();
}
$entity->set($data);
$this->fieldValidationManager->process($entity, $data);
$this->entityManager->saveEntity($entity);
$entity->set(Field::NAME, $user->getName());
// @todo Remove.
$entity->clear('smtpPassword');
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function resetToDefaults(string $userId): void
{
$this->processAccessCheck($userId);
$result = $this->getRepository()->resetToDefaults($userId);
if (!$result) {
throw new NotFound();
}
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function resetDashboard(string $userId): stdClass
{
$this->processAccessCheck($userId);
if ($this->acl->getLevel(Preferences::ENTITY_TYPE, Table::ACTION_EDIT) === Table::LEVEL_NO) {
throw new Forbidden();
}
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$user) {
throw new NotFound();
}
if (!$preferences) {
throw new NotFound();
}
if ($user->isPortal()) {
throw new Forbidden();
}
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_EDIT);
if (in_array('dashboardLayout', $forbiddenAttributeList)) {
throw new Forbidden();
}
$dashboardLayout = $this->config->get('dashboardLayout');
$dashletsOptions = $this->config->get('dashletsOptions');
$preferences->set([
'dashboardLayout' => $dashboardLayout,
'dashletsOptions' => $dashletsOptions,
]);
$this->entityManager->saveEntity($preferences);
return (object) [
'dashboardLayout' => $preferences->get('dashboardLayout'),
'dashletsOptions' => $preferences->get('dashletsOptions'),
];
}
private function getRepository(): Repository
{
/** @var Repository */
return $this->entityManager->getRepository(Preferences::ENTITY_TYPE);
}
}

View File

@@ -0,0 +1,404 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Tools\App;
use Espo\Core\Mail\ConfigDataProvider as EmailConfigDataProvider;
use Espo\Core\Utils\ThemeManager;
use Espo\Entities\Email;
use Espo\Entities\Settings;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider;
use Espo\Core\ApplicationState;
use Espo\Core\Acl;
use Espo\Core\InjectableFactory;
use Espo\Core\DataManager;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\Utils\Currency\DatabasePopulator as CurrencyDatabasePopulator;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Core\Utils\Config\Access;
use Espo\Entities\Portal;
use Espo\Repositories\Portal as PortalRepository;
use stdClass;
class SettingsService
{
public function __construct(
private ApplicationState $applicationState,
private Config $config,
private ConfigWriter $configWriter,
private Metadata $metadata,
private Acl $acl,
private EntityManager $entityManager,
private DataManager $dataManager,
private FieldValidationManager $fieldValidationManager,
private InjectableFactory $injectableFactory,
private Access $access,
private AuthenticationMethodProvider $authenticationMethodProvider,
private ThemeManager $themeManager,
private Config\SystemConfig $systemConfig,
private EmailConfigDataProvider $emailConfigDataProvider,
private Acl\Cache\Clearer $aclCacheClearer,
) {}
/**
* Get config data.
*/
public function getConfigData(): stdClass
{
$data = $this->config->getAllNonInternalData();
$this->filterDataByAccess($data);
$this->filterData($data);
$this->loadAdditionalParams($data);
return $data;
}
/**
* Get metadata to be used in config.
*/
public function getMetadataConfigData(): stdClass
{
$data = (object) [];
unset($data->loginView);
$loginView = $this->metadata->get(['clientDefs', 'App', 'loginView']);
if ($loginView) {
$data->loginView = $loginView;
}
$loginData = $this->getLoginData();
if ($loginData) {
$data->loginData = (object) $loginData;
}
return $data;
}
/**
* @return ?array{
* handler: string,
* fallback: bool,
* data: stdClass,
* method: string,
* }
*/
private function getLoginData(): ?array
{
$method = $this->authenticationMethodProvider->get();
/** @var array<string, mixed> $mData */
$mData = $this->metadata->get(['authenticationMethods', $method, 'login']) ?? [];
/** @var ?string $handler */
$handler = $mData['handler'] ?? null;
if (!$handler) {
return null;
}
$isProvider = $this->isPortalWithAuthenticationProvider();
if (!$isProvider && $this->applicationState->isPortal()) {
/** @var ?bool $portal */
$portal = $mData['portal'] ?? null;
if ($portal === null) {
/** @var ?string $portalConfigParam */
$portalConfigParam = $mData['portalConfigParam'] ?? null;
$portal = $portalConfigParam && $this->config->get($portalConfigParam);
}
if (!$portal) {
return null;
}
}
/** @var ?bool $fallback */
$fallback = !$this->applicationState->isPortal() ?
($mData['fallback'] ?? null) :
false;
if ($fallback === null) {
/** @var ?string $fallbackConfigParam */
$fallbackConfigParam = $mData['fallbackConfigParam'] ?? null;
$fallback = $fallbackConfigParam && $this->config->get($fallbackConfigParam);
}
if ($isProvider) {
$fallback = false;
}
/** @var stdClass $data */
$data = (object) ($mData['data'] ?? []);
return [
'handler' => $handler,
'fallback' => $fallback,
'method' => $method,
'data' => $data,
];
}
private function isPortalWithAuthenticationProvider(): bool
{
if (!$this->applicationState->isPortal()) {
return false;
}
$portal = $this->applicationState->getPortal();
return (bool) $this->authenticationMethodProvider->getForPortal($portal);
}
/**
* Set config data.
*
* @throws BadRequest
* @throws Forbidden
* @throws Error
*/
public function setConfigData(stdClass $data): void
{
$user = $this->applicationState->getUser();
if (!$user->isAdmin()) {
throw new Forbidden();
}
$ignoreItemList = array_merge(
$this->access->getSystemParamList(),
$this->access->getReadOnlyParamList(),
$this->isRestrictedMode() && !$user->isSuperAdmin() ?
$this->access->getSuperAdminParamList() : []
);
foreach ($ignoreItemList as $item) {
unset($data->$item);
}
$entity = $this->entityManager->getNewEntity(Settings::ENTITY_TYPE);
$entity->set($data);
$entity->setAsNotNew();
$this->processValidation($entity, $data);
if (
isset($data->useCache) &&
$data->useCache !== $this->systemConfig->useCache()
) {
$this->dataManager->clearCache();
}
$this->configWriter->setMultiple(get_object_vars($data));
$this->configWriter->save();
if (isset($data->personNameFormat)) {
$this->dataManager->clearCache();
}
if (property_exists($data, 'baselineRoleId')) {
$this->aclCacheClearer->clearForAllInternalUsers();
}
if (isset($data->defaultCurrency) || isset($data->baseCurrency) || isset($data->currencyRates)) {
$this->populateDatabaseWithCurrencyRates();
}
}
private function loadAdditionalParams(stdClass $data): void
{
if ($this->applicationState->isPortal()) {
$portal = $this->applicationState->getPortal();
$this->getPortalRepository()->loadUrlField($portal);
$data->siteUrl = $portal->get('url');
}
if (
(
$this->emailConfigDataProvider->getSystemOutboundAddress() ||
$this->config->get('internalSmtpServer')
) &&
!$this->config->get('passwordRecoveryDisabled')
) {
$data->passwordRecoveryEnabled = true;
}
$data->logoSrc = $this->themeManager->getLogoSrc();
}
private function filterDataByAccess(stdClass $data): void
{
$user = $this->applicationState->getUser();
$ignoreItemList = [];
foreach ($this->access->getSystemParamList() as $item) {
$ignoreItemList[] = $item;
}
foreach ($this->access->getInternalParamList() as $item) {
$ignoreItemList[] = $item;
}
if (!$user->isAdmin() || $user->isSystem()) {
foreach ($this->access->getAdminParamList() as $item) {
$ignoreItemList[] = $item;
}
}
/*if ($this->isRestrictedMode() && !$user->isSuperAdmin()) {
// @todo Maybe add restriction level for non-super admins.
}*/
foreach ($ignoreItemList as $item) {
unset($data->$item);
}
if ($user->isSystem()) {
$globalItemList = $this->access->getGlobalParamList();
foreach (array_keys(get_object_vars($data)) as $item) {
if (!in_array($item, $globalItemList)) {
unset($data->$item);
}
}
}
}
private function filterEntityTypeParams(stdClass $data): void
{
$entityTypeListParamList = $this->metadata->get(['app', 'config', 'entityTypeListParamList']) ?? [];
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['entityDefs'], []));
foreach ($scopeList as $scope) {
if (!$this->metadata->get(['scopes', $scope, 'acl'])) {
continue;
}
if ($this->acl->tryCheck($scope)) {
continue;
}
foreach ($entityTypeListParamList as $param) {
$list = $data->$param ?? [];
foreach ($list as $i => $item) {
if ($item === $scope) {
unset($list[$i]);
}
}
$data->$param = array_values($list);
}
}
}
private function populateDatabaseWithCurrencyRates(): void
{
$this->injectableFactory->create(CurrencyDatabasePopulator::class)->process();
}
private function filterData(stdClass $data): void
{
$user = $this->applicationState->getUser();
if (!$user->isAdmin() && !$user->isSystem()) {
$this->filterEntityTypeParams($data);
}
$fieldDefs = $this->metadata->get(['entityDefs', 'Settings', 'fields']);
foreach ($fieldDefs as $field => $fieldParams) {
if ($fieldParams['type'] === 'password') {
unset($data->$field);
}
}
if (empty($data->useWebSocket)) {
unset($data->webSocketUrl);
}
if ($user->isSystem()) {
return;
}
if ($user->isAdmin()) {
return;
}
if (
!$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE) ||
!$this->emailConfigDataProvider->isSystemOutboundAddressShared()
) {
unset($data->outboundEmailFromAddress);
unset($data->outboundEmailFromName);
unset($data->outboundEmailBccAddress);
}
}
private function isRestrictedMode(): bool
{
return (bool) $this->config->get('restrictedMode');
}
/**
* @throws BadRequest
*/
private function processValidation(Entity $entity, stdClass $data): void
{
$this->fieldValidationManager->process($entity, $data);
}
private function getPortalRepository(): PortalRepository
{
/** @var PortalRepository */
return $this->entityManager->getRepository(Portal::ENTITY_TYPE);
}
}